Search code examples
typescriptgenericscompiler-warningsmapped-typestype-assertion

Is it possible to avoid the type assertion in this generic function body?


As a use case, I want to allow people to add an arbitrary function to an object, and call that function through another, using its name and parameters.

// Example arbitrary function
const sum = (a: number, b: number): number => a + b

// Another such function
const saySomething = (): string => Math.random() < 0.5 ? 'morning' : 'evening'

// This object holds the functions declared above
const execFn: Record<string, (...args: any[]) => any> = { sum, saySomething }

// These functions can be called through a generic function
export const exec = (name: string, ...args: any[]): any => execFn[name](...args)

console.log(exec('sum', 1, 2))

This works, but it's not type safe. E.g., someone can input the incorrect parameters. I can improve this using mapped types, as such:

type GenericFn = keyof typeof execFn

type GenericParameters = {
  [K in GenericFn]: Parameters<(typeof execFn)[K]>
}

type GenericReturn = {
  [K in GenericFn]: Parameters<(typeof execFn)[K]>
}

And then modifying the exec function signature:

// These functions can be called through a generic function
export const exec = <T extends GenericFn>(
  name: T,
  ...args: GenericParameters[T]
): GenericReturn[T] => (execFn[name] as (...args: any[]) => any)(...args)

And now, TS will properly tell if someone is calling a function without the correct parameters, and the return type is properly typed.

console.log(exec('sum', 1)) // Expected 3 arguments, but got 2. ts(2554)

How can I remove the type assertion in the exec function though?

// These functions can be called through a generic function
export const exec = <T extends GenericFn>(
  name: T,
  ...args: GenericParameters[T]
): GenericReturn[T] => execFn[name](...args)

Removing the type assertion throws the following TS error.

Type 'string | number' is not assignable to type 'GenericReturn[T]'.
  Type 'string' is not assignable to type 'GenericReturn[T]'.
    Type 'string' is not assignable to type 'never'.
      The intersection '[a: number, b: number] & []' was reduced to 'never'
      because property 'length' has conflicting types in some constituents.ts(2322)

Trying to isolate the function gives me impression that the generic function is not getting exact function out of the object.

const fn = execFn[name]

// const fn: {
//   sum: (a: number, b: number) => number;
//   saySomething: () => string;
// }[T]

TypeScript playground


Solution

  • This is actually quite close to the recommended approach for dealing with certain input-output type dependencies, as described in microsoft/TypeScript#47109. In order for the following code to work:

    export const exec = <K extends GenericFn>(
      name: K,
      ...args: GenericParameters[K]
    ): GenericReturn[K] => execFn[name](...args)
    

    the compiler needs to see execFn[name] as being a single function of generic type (...args: GenericParameters[K]) => GenericReturn[K]. Which means that it needs to see execFn is something that behaves generically when indexed by a key of type K.

    According to microsoft/TypeScript#47109, this will happen if execFn is represented as a mapped type over the keys K in GenericFn. Specifically:

    const execFn: { 
      [K in GenericFn]: (...args: GenericParameters[K]) => GenericReturn[K] 
    } = ⋯;
    

    Since your GenericParameters and GenericReturn types are themselves written in terms of typeof execFn, we can't do that directly without circularity. Let's rename your execFn out of the way to _execFn:

    const _execFn = {
      isEven, randomInt, repeat, saySomething, sum,
    } as const;
    
    type GenericFn = keyof typeof _execFn
    
    type GenericParameters = {
      [K in GenericFn]: Parameters<(typeof _execFn)[K]>
    }
    
    type GenericReturn = {
      [K in GenericFn]: ReturnType<(typeof _execFn)[K]>
    }
    

    And now we can assign _execFn to execFn of the appropriate type:

    const execFn: {
      [K in GenericFn]: (...args: GenericParameters[K]) => GenericReturn[K]
    } = _execFn;
    

    And that works.


    Note that this hinges on the form of the type of execFn. If you look at the type of execFn via IntelliSense:

    /* const execFn: {
        isEven: (n: number) => boolean;
        randomInt: (upTo: number) => number;
        repeat: (word: string, times: number) => string[];
        saySomething: () => string;
        sum: (a: number, b: number) => number;
    } */
    

    and compare it to the displayed type for _execFn:

    /* const _execFn: {
        readonly isEven: (n: number) => boolean;
        readonly randomInt: (upTo: number) => number;
        readonly repeat: (word: string, times: number) => string[];
        readonly saySomething: () => string;
        readonly sum: (a: number, b: number) => number;
    } */
    

    those are basically the same (readonly doesn't matter here). And of course they need to be, or else the const execFn: ⋯ = _execFn assignment would fail.

    But the internal representation of the types are different. The type of execFn is explicitly a mapped type in which each property has a generic relationship, whereas the type of _execFn is just a list of possibly unrelated properties. The compiler lacks the ability to look at _execFn and come up with the type of execFn by itself; it needs to be explicitly told.

    So the type of execFn[name] is a single function that accepts a parameter list of type GenericParameters[K], which is good. But the type of _execFn[name] ends up getting widened to a union of functions, which is pretty useless since unions of functions can only be safely called with an intersection of arguments (see the TS3.3 release notes). So _execFn[name] wants a parameter list of type never (i.e., there's no safe way to call this union of functions because no argument works for every call signature).

    In fact the issue that eventually led to the microsoft/TypeScript#47109 approach was about these sorts of useless unions of functions, as described in microsoft/TypeScript#30581. That issue is phrased in terms of "correlated unions", where you have a value v of a union type like {k: "sum", fn: (a: number, b: number) => number, args: [a: number, b: number]} | {k: "saySomething", fn: () => string, args: []} | ⋯} and you want to call v.fn(...v.args), but the compiler doesn't let you. Generic indexes are part of the solution, but you also need to have mapped types.

    Playground link to code