Hur ska man upprätthålla typesäkerhet i TypeScript när man använder Parameters med generiska funktionstyper?


Jag försöker uppnå typsäkerhet i TypeScript när jag använder verktygstypen Parameters. Men jag stöter på ett problem där typen Parameters inte tar hänsyn till en generisk funktionstyp TFunc när den härleder argumenttypen.

Här är ett exempel på de inblandade funktionerna:

const funcExecutor = <TFunc extends (...args: any[]) => void>(data: {
  func: TFunc;
  mapParams: () => Parameters<TFunc>[0];
}) => data.func(data.mapParams());

const funcExecutorHandler = <TAction extends (arg: any) => any>(
  action: TAction,
  data: Parameters<TAction>[0],
) => {};

Funktionen funcExecutor är typ-säker och kan anropas på följande sätt:

funcExecutor({
  func: (param: { foo: boolean }) => param,
  mapParams() {
    return {
      foo: true, // TYPE SAFE
    };
  },
});

Problemet uppstår när man använder funcExecutorHandler-funktionen, som är en generell funktion som kan ta vilken funktion som helst och förväntar sig parametrar baserade på den angivna funktionens parametertyper. I detta exempel är avsikten att upprätthålla typesäkerhet för mapParams-funktionen. Dock misslyckas den med att göra det.

funcExecutorHandler(funcExecutor, {
  func: (param: { foo: boolean }) => param,
  mapParams() {
    return {
      foo: 'bad', // NOT TYPE SAFE
    };
  },
});

Problemet ligger i det faktum att typen Parameters<TAction>[0] inte tar hänsyn till den generiska funktionstypen TFunc, vilket resulterar i oförmågan att säkerställa typsäkerhet för funktionen mapParams.

Finns det ett sätt att göra Parameters-typinferens beroende av den generiska funktionstypen TFunc i detta scenario? Alla förslag eller alternativa lösningar skulle uppskattas mycket. Tack!

Svar #1

TypeScript är i princip oförmöget att utföra den typ av högre ordningens typ-manipulation som du letar efter. Inferensalgoritmen kan inte utföra godtycklig generisk typinstansiering, vilket är vad du skulle behöva för att få detta att fungera. Se microsoft/TypeScript#52010 och microsoft/TypeScript#49690 för mer information.

Så Parameters<T>-verktygstypen kommer inte att kunna syntetisera en typ som är generisk på det sätt du behöver. Se microsoft/TypeScript#43731 för mer information.

Vad du kommer att se i slutändan är att de "inre" generiska typparametrarna kommer att ersättas med deras begränsningar, vilket ofta är unknown.


Dessutom implementeras Parameters<T> som en villkorlig typ, och typkontrollen kan egentligen inte utföra noggrann analys på sådana typer när T är generisk, till exempel inuti kroppen på funcExecutor(). Den kommer ibland att klaga på saker som är säkra eftersom den lämnar typen opak och kan inte lista ut vad som kan tilldelas den. Andra gånger försöker den göra framsteg genom att utvärdera typen för tidigt och tillåta osäkra saker. Till exempel är implementeringen av din funcExecutor-funktion osäker men kompilatorn missar det:

funcExecutor({
    func: (x: string, y: number) => console.log(y.toFixed()),
    mapParams: () => ""
}); // 💥 y is undefined

Det samtalet uppfyller samtalsignaturen för funcExecutor(), med TFunc av typen (x: string, y: number) => void, men du får ett körfel eftersom endast det första argumentet skickas. Kroppen av funcExecutor() borde egentligen ha ett kompileringsfel, men kompilatorn missade problemet eftersom den gjorde ett felaktigt antagande om hur Parameters<TFunc> ser ut.


Mitt förslag här är att refaktorera bort från villkorliga typer (dina funktioner behöver egentligen bara vara generiska för den enskilda argumenttypen, inte hela funktionstypen), och att använda ett instansieringsuttryck för att specificera typargumentet som kompilatorn inte kan härleda. Så här:

const funcExecutor = <A,>(data: {
    func: (arg: A) => void;
    mapParams: () => A;
}) => data.func(data.mapParams());

const funcExecutorHandler = <A,>(
    action: (arg: A) => void,
    data: A,
) => { };

funcExecutor({
    func: (param: { foo: boolean }) => param,
    mapParams() {
        return {
            foo: true, // TYPE SAFE
        };
    },
});

funcExecutor({
    func: (x: string, y: number) => console.log(y.toFixed()), // error!
    mapParams: () => ""
}) 

funcExecutorHandler(funcExecutor<{ foo: boolean }>, {
    // -----------------------> ^^^^^^^^^^^^^^^^^^
    func: (param: { foo: boolean }) => param,
    mapParams() { // error!
        return {
            foo: 'bad',
        };
    },
});

Nu får du fel där du förväntar dig dem: det osäkra anropet till funcExecutor() är nu ett kompilatorfel eftersom det kräver fler parametrar än vad anropssignaturen tillåter; och anropet funcExecutorHandler() misslyckas eftersom det vet att {foo: "bad"} inte är ett {foo: boolean}.

Detta är lite mer typen specificerande än du skulle vilja, men det är inte så illa, särskilt eftersom det frigör dig från att behöva annotera func-callback-parametertypen, så det är ungefär lika i termer av komplexitet:

funcExecutorHandler(funcExecutor<{ foo: boolean }>, {
    func: (param) => param, // known to be {foo: boolean}
    mapParams: () => ({ foo: true })
});

Playground-länk till kod