I am attempting to implement a "proxy" method, which is able to call other methods of the class dynamically. All works well until I start playing around with types of other methods arguments. Following example gives A spread argument must either have a tuple type or be passed to a rest parameter.
What am I doing wrong? According to docs, Parameters
utility function returns tuple :(
class testClass {
public methodA(a: number): void {
}
public methodB(b: string): void {
}
public callRandom(methodName: 'methodA', options: [number]): void;
public callRandom(methodName: 'methodB', options: [string]): void;
public callRandom(methodName: 'methodA' | 'methodB', options: [number] | [string]) {
type optionsType = Parameters<this[typeof methodName]>;
this[methodName](...options as optionsType);
}
}
You've run into a known bug / limitation of TypeScript, described at microsoft/TypeScript#36874 and microsoft/TypeScript#42508 (and issues linked therein). If you're trying to spread something into a function call TypeScript currently only seems to allow that if its type is a single tuple. Unions of tuples, or generics that are constrained to tuples, or conditional types that evaluate to tuples are not supported.
If you want to just push ahead and prevent the error, you can use a type assertion:
public callRandom(methodName: 'methodA' | 'methodB', options: [number] | [string]) {
type optionsType = Parameters<this[typeof methodName]>;
this[methodName](...options as [never]);
}
So, [never]
is a single tuple type, so it's seen as spreadable. But why never
? That's because the compiler has no idea whether methodName
is "methodA"
or "methodB"
. It's a union of the two. So this[methodName]
will be a union of functions. And a union of functions can only be called safely with an intersection of its parameter types. See the TS3.3 release notes for a description of that functionality. Since methodA
wants a number
, and methodB
wants a string
, you can only safely pass something which is both a number
and a string
, so it doesn't matter which method you're calling. Uh, but there is no such thing. number & string
is reduced to the impossible never
type. Thus the only thing the compiler will let you pass as options
is [never]
.
You might be thinking that methodName
and options
are correlated to each other in such a way that it's always appropriate to call this[methodName](...options)
, but that correlation is not something the compiler sees. The implementation of your function has methodName
and options
as two independent union types, so as far as the compiler knows inside that implementation, it's quite possible for methodName
to be "methodA"
while options
is [string]
. TypeScript really doesn't have direct support for correlated unions, as described in microsoft/TypeScript#30581.
If you want the compiler to actually "understand" that this[methodName](...options)
is always appropriate, then you need to refactor your code as described in microsoft/TypeScript#47109. The idea is to move away from unions (and overloads in your case), and toward generics. You represent your operations in terms of a "basic" key-value type, and in terms of generic indexes into that type, and in terms of generic indexes into mapped types over that type.
For your example it might look like
interface MethodParam {
methodA: [number],
methodB: [string]
}
class TestClass {
public methodA(a: number): void {
}
public methodB(b: string): void {
}
public callRandom<K extends keyof MethodParam>(
methodName: K,
options: MethodParam[K]
) {
const t: { [P in keyof MethodParam]: (...args: MethodParam[P]) => void } = this;
t[methodName](...options);
}
}
The basic type is MethodParam
, and we assign this
to a variable t
of the mapped type { [P in keyof MethodParam]: (...args: MethodParam[P]) => void }
. The compiler sees that assignment as acceptable (because it goes through each property of MethodParam
and can verify that TestClass
has an appropriately shaped method at that property). And methodName
is a key of MethodParam
of generic type K
. And options
is a generic index into MethodParam
, MethodParam[K]
.
Now t[methodName]
is a generic index into that mapped type, and can be seen as the single function type (...args: MethodParam[K]) => void
. Since options
is of type MethodParam[K]
, then t[methodName](...options)
is acceptable. No unions of functions are involved, and no intersections of parameters are required.
This refactoring might not be worth it for your use case. You might care more about expedience ("stop complaining, compiler, and let me get on with my day") than protection ("I need you to understand what I'm doing, compiler, so that if I make a mistake you'll catch it"). If so, then a type assertion is the way to go.