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]
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:
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 seeexecFnis something that behaves generically when indexed by a key of typeK.According to microsoft/TypeScript#47109, this will happen if
execFnis represented as a mapped type over the keysKinGenericFn. Specifically:Since your
GenericParametersandGenericReturntypes are themselves written in terms oftypeof execFn, we can't do that directly without circularity. Let's rename yourexecFnout of the way to_execFn:And now we can assign
_execFntoexecFnof the appropriate type:And that works.
Note that this hinges on the form of the type of
execFn. If you look at the type ofexecFnvia IntelliSense:and compare it to the displayed type for
_execFn:those are basically the same (
readonlydoesn't matter here). And of course they need to be, or else theconst execFn: ⋯ = _execFnassignment would fail.But the internal representation of the types are different. The type of
execFnis explicitly a mapped type in which each property has a generic relationship, whereas the type of_execFnis just a list of possibly unrelated properties. The compiler lacks the ability to look at_execFnand come up with the type ofexecFnby itself; it needs to be explicitly told.So the type of
execFn[name]is a single function that accepts a parameter list of typeGenericParameters[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 typenever(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
vof 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 callv.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