I have an object that contains functions:
const bazApi = {
fun1: () => string,
fun2: (n: number) => void,
fun3: (s: string) => Promise<number>,
}
Then I have a function that takes a key of that object and a tuple of parameters matching the input of the associated function, i.e. fun2
and [42]
. The function then uses the identifier to get the function from the object and calls it using the provided arguments. However, Typescript complains:
A spread argument must either have a tuple type or be passed to a rest parameter.ts(2556)
I have read up on the error, but I don’t think it is the underlying problem, which is rather TS suddenly not being able to match function and parameters.
Here is the code (and here it is at the playground):
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)
};
Interestingly, in the line above the error, where handler
is set, typescript seems to know that it is the function from the object identified by the command name, i.e. BazApiInterface[K]
, and I can even get it to write it out as
const handler: ApiInterface<{
fun1: () => string;
fun2: (n: number) => void;
fun3: (s: string) => Promise<number>;
}>[CommandName]
Also, args
gives similar output, TS seems to know that it contains the parameters for the function identified by CommandName
. So it seems like TS should be able to figure out that they match.
However, when actually calling the function in the next line, TS sees handler
either as a union type over all functions in the object (in TS v4.7.4) or as some weird amalgamation of the functions ((arg0: never) => string | void | Promise<number>
) in TS v4.9.5, and then correctly complains that something isn’t right. But it would be if the function type hadn’t changed, wouldn’t it?
Currently, I just cheat (const res = (handler as Function)(...args);
), but I would really like to know why it happens and if I can do anything about it?
Feels like this question is asked every other day, but I couldn’t find an explanation. This answer and the linked issue in TS’ Github seems very close, but I don’t think it applies (or at least I can’t).
8
The problem you’re having with
const res = handler(...args);
Is that the compiler can’t follow the correlation between the type of handler
and the type of args
. The error message about spread/rest is misleading, see microsoft/TypeScript#47615. The Parameters<T>
utility type is implemented as a conditional type and is thus deferred when its argument is generic. While a human being can read func(...args)
where func
is of generic type F
and args
is of generic type Parameters<F>
and say “yeah that works”, the compiler doesn’t see it that way, since it doesn’t really know the purpose of Parameters
. You can only call func(...args)
generically if the compiler knows that args
is of type A
and func
is of type (...args: A) => any
.
If you want to try refactoring so the compiler does follow the correlation, you can indeed use the technique mentioned in microsoft/TypeScript#47109, where we try to represent things in terms of generic indexed accesses into mapped types.
For example:
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
};
The Api<T>
type is essentially an identity function on T
, more or less, and indeed Api<BazInterface>
is equivalent to BazInterface
. And the compiler is happy to allow you to assign bazApi
to _bazApi
. But since Api<T>
is a mapped type, then so is the type of _bazApi
. So _bazApi[command]
is now an indexed access into a mapped type, which the compiler can directly evaluate as being type (...args: Parameters<BazInterface[K]>) => ReturnType<BazInterface[K]>
. And since args
is of type Parameters<BazInterface[K]>
, the call succeeds.
Again, it might seem silly that you’re rewriting BazInterface
to an equivalent type and now things work, but you can think of it as leading the compiler through the exercise of understanding the generic relationship between handler
and args
.
Playground link to code
2