Typescript vil ikke sprede generiske parametre ind i matchende funktion


Jeg har et objekt, der indeholder funktioner:

const bazApi = {
  fun1: () => string,
  fun2: (n: number) => void,
  fun3: (s: string) => Promise<number>,
}

Så har jeg en funktion, der tager en nøgle til det objekt og en tuple af parametre, der matcher inputtet til den tilknyttede funktion, f.eks. fun2 og [42]. Funktionen bruger derefter identifikatoren til at hente funktionen fra objektet og kalder den med de angivne argumenter. Dog klager Typescript:

Et spread-argument skal enten have en tuple-type eller videregives til en restparameter.ts(2556)

Jeg har læst op på fejlen, men jeg tror ikke, det er det underliggende problem, som snarere er, at TS pludselig ikke kan matche funktion og parametre.

Her er koden (og her er den på legepladsen):

type BazApiInterface = {
    fun1: () => string,
    fun2: (n: number) => void,
    fun3: (s: string) => Promise<number>,
}

type Message<T extends Record<K, (...args: any) => any>, K extends keyof T> = {
    command: K,
    args: Parameters<T[K]>
}

type ApiListener<T extends Record<keyof T, (...args: any) => any>> = <K extends keyof T>(
    message: Message<T, K>
) => void

declare const bazApi: BazApiInterface
const listener: ApiListener<BazApiInterface> = (message) => {
    const { command, args } = message;
    const handler = bazApi[command];    // TS says handler is function from BazApiInterface indexed by CommandName (good)
    const res = handler(...args);       // TS says handler is union over (in TS 4.7.4) or a merge of (?) all function from BazApiInterface (not good)
};

Interessant nok ser det ud til, at i linjen over fejlen, hvor handler sættes, ved TypeScript, at det er funktionen fra objektet identificeret af kommandoens navn, dvs. BazApiInterface[K], og jeg kan endda få det til at skrive det ud som

const handler: ApiInterface<{
  fun1: () => string;
  fun2: (n: number) => void;
  fun3: (s: string) => Promise<number>;
}>[CommandName]

Også giver args lignende output, TS ser ud til at vide, at det indeholder parametrene for funktionen identificeret af CommandName. Så det ser ud til, at TS burde kunne finde ud af, at de matcher.

Men når man faktisk kalder funktionen på næste linje, ser TS handler enten som en unionstype over alle funktioner i objektet (i TS v4.7.4) eller som en slags mærkelig blanding af funktionerne ((arg0: never) => string | void | Promise<number>) i TS v4.9.5, og klager derefter korrekt over, at noget ikke er rigtigt. Men det ville det jo ikke være, hvis funktionstypen ikke havde ændret sig, ville det?

Lige nu snyder jeg bare (const res=(handler as Function)(...args);), men jeg vil virkelig gerne vide, hvorfor det sker, og om jeg kan gøre noget ved det?

Det føles som om dette spørgsmål bliver stillet hver anden dag, men jeg kunne ikke finde en forklaring. Dette svar og det linkede problem i TS' Github virker meget tæt på, men jeg tror ikke, det gælder (eller i det mindste kan jeg ikke få det til at gælde).

Svar #1

Problemet, du har med

const res = handler(...args);

Det er, fordi compileren ikke kan følge sammenhængen mellem typen af handler og typen af args. Fejlmeddelelsen om spread/rest er misvisende, se microsoft/TypeScript#47615. Parameters<T> hjælpertypen er implementeret som en betinget type og er derfor udskudt, når dens argument er generisk. Mens et menneske kan læse func(...args), hvor func er af generisk type F og args er af generisk type Parameters<F> og sige "ja, det virker", ser compileren det ikke sådan, da den egentlig ikke kender formålet med Parameters. Du kan kun kalde func(...args) generisk, hvis compileren ved, at args er af typen A og func er af typen (...args: A) => any.


Hvis du vil prøve at refaktorere, så compileren faktisk følger korrelationen, kan du faktisk bruge teknikken nævnt i microsoft/TypeScript#47109, hvor vi prøver at repræsentere ting i form af generiske indekserede adgang til kortlagte typer.

For eksempel:

declare const bazApi: BazApiInterface

type Api<T extends Record<keyof T, (...args: any) => any>> =
    { [K in keyof T]: (...args: Parameters<T[K]>) => ReturnType<T[K]> }

const listener: ApiListener<BazApiInterface> =
    (message) => {
        const _bazApi: Api<BazApiInterface> = bazApi;
        const { command, args } = message;
        const handler = _bazApi[command];
        const res = handler(...args); // okay
    };

Typen Api<T> er i det væsentlige en identitetsfunktion på T, mere eller mindre, og faktisk er Api<BazInterface> ækvivalent med BazInterface. Og compileren tillader gerne, at du tildeler bazApi til _bazApi. Men da Api<T> er en mapper-type, er typen af _bazApi det også. Så _bazApi[command] er nu en indekseret adgang til en mapper-type, som compileren direkte kan evaluere som værende af typen (...args: Parameters<BazInterface[K]>) => ReturnType<BazInterface[K]>. Og da args er af typen Parameters<BazInterface[K]>, lykkes kaldet.

Igen kan det virke fjollet, at du omskriver BazInterface til en tilsvarende type, og nu virker tingene, men du kan tænke på det som at føre compileren gennem øvelsen med at forstå det generiske forhold mellem handler og args.

Playground-link til kode

Relaterede artikler