I want to create a function that takes care of calling some other functions. These other functions should trigger similar business and cleanup logic.
function foo(arg: number) {
// do something with arg
}
function bar(arg: string) {
// do something with arg
}
const FUNCTIONS = {
foo,
bar,
} as const;
type FnType = keyof typeof FUNCTIONS;
type FnArg<Type extends FnType> = Parameters<typeof FUNCTIONS[Type]>[0];
function callFunction<Type extends FnType>(type: Type, arg: FnArg<Type>) {
// some business logic here that is shared by all functions
const fn = FUNCTIONS[type];
// the type of fn and arg are both derived from Type:
// - 'typeof fn' would be 'typeof Functions[Type]'
// - 'typeof args' would be 'FnArg<Type>'
// However, TS seems to see these 2 types as independent and cannot
// figure out that fn and arg can work together.
return fn(arg); // -> TS doesn't 'know' that arg should have the right type although we know that's the case thanks to generics
}
// generics make sure the second argument is of the right type
callFunction('foo', 5);
callFunction('foo', 'arg'); // errors as expected
callFunction('bar', 'arg');
callFunction('bar', 5); // erros as expected
I'm able to use generics to make sure TS checks that the arguments that will be proxied to these functions are of the right type. However, within the function implementation, TS does not seem to know that the generics will make sure the argument is of the right type.
Do you know if there is a way to make TS understand that calling fn(arg) will be fine?
As you saw, the compiler cannot see the correlation between the type of FUNCTIONS[type] and the type of arg. The issue is essentially the same as described in microsoft/TypeScript#30581, where it's phrased in terms of correlated union types. Unless you're very careful, when the compiler tries to call FUNCTIONS[type](arg), it will widen the generic type of FUNCTIONS[type] to the union ((arg: string) => void) | (arg: number) => void)), and the type of arg to string | number, and unfortunately you cannot call the former with an argument of the latter. Indeed the compiler collapses the union of functions to the single function of an intersection of its parameters (as described in the release notes for TS3.3) and you get (arg: never) => void, which cannot be called at all, since it requires an argument of the impossible never type; something can't be both a string and a number. So if you do it this way, you're stuck:
function callFunction<K extends keyof FnArg>(type: K, arg: FnArg[K]) {
const fn = FUNCTIONS[type];
// const fn: (arg: never) => void
// (parameter) arg: string | number
return fn(arg); // error! 😢
}
Luckily there is a way to fix it, as described in microsoft/TypeScript#47109. The idea is to refactor your types so that they are explicitly written as operations on generic indexed accesses into mapped types over a simple or "base" object type. The point is that you want fn to be seen as having the generic type (arg: XXX) => void and you want arg to be seen as type XXX for the same generic XXX. So the type of FUNCTIONS has to be changed to map over that base type.
Here's one way to do it. First we rename your FUNCTIONS variable out of the way:
const _FUNCTIONS = {
foo,
bar,
} as const;
Then we use it to construct that base object type:
type FnArg = { [K in keyof typeof _FUNCTIONS]:
Parameters<typeof _FUNCTIONS[K]>[0] }
/* type FnArg = {
readonly foo: number;
readonly bar: string;
} */
And then we declare FUNCTIONS to be a mapped type over FnArg:
const FUNCTIONS: {
[K in keyof FnArg]: (arg: FnArg[K]) => void
} = _FUNCTIONS;
And now you can write callFunction() as operating on generic indexed accesses:
function callFunction<K extends keyof FnArg>(type: K, arg: FnArg[K]) {
const fn = FUNCTIONS[type];
// const fn: (arg: FnArg[K]) => void
// (parameter) arg: FnArg[K]
return fn(arg); // okay
}
There you go!
Note that the type of FUNCTIONS and the type of _FUNCTIONS are equivalent, especially if you ask IntelliSense to display them:
/* const _FUNCTIONS: {
readonly foo: (arg: number) => void;
readonly bar: (arg: string) => void;
} */
/* const FUNCTIONS: {
readonly foo: (arg: number) => void;
readonly bar: (arg: string) => void;
} */
The only difference between them is how they are internally represented, and the mapped type FUNCTIONS type allows the compiler to see the correlation betwen FUNCTIONS[type] and arg. If you replace FUNCTIONS with _FUNCTIONS inside the body of callFuction(), your original error will come right back.
The issue is therefore a subtle one, and all I can do here is to point people to microsoft/TypeScript#47109 for a source for this technique, where the implementer explains how and why it works.
Playground link to code
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With