We’ve defined a base class that will be subclassed many times. We want to define an exec
method on the base class that accepts the name and arguments of a method on a derived class, and then calls it.
However, we’ve run into an issue with the typing of the arguments of the derived class’s method. Here’s a snippet that demonstrates the error:
class BaseClass {
exec<
Self extends Record<MethodKey, (...args: any) => void>,
MethodKey extends keyof Self,
Method extends Self[MethodKey],
>(
this: Self,
methodKey: MethodKey,
...args: Parameters<Method>
) {
this[methodKey](...args);
}
}
class MyClass extends BaseClass {
constructor() {
super();
this.exec(`sayMessage`, `Hello`); // Error: Argument of type '["Hello"]' is not assignable to parameter of type 'Parameters<this["sayMessage"]>'.
}
sayMessage(message: string) {
console.log(message);
}
}
Why doesn’t this work, and how do we correctly type the exec
method so that it accepts the other method’s arguments?
2
Answers
I don't entirely understand why, but it works when Args is defined prior to the method:
TS Playground link
Credit to this chat in the Typescript discord: https://discord.com/channels/508357248330760243/1215722676904132648/1215722676904132648
The main issue here is actually that the type inferred for
Self
is the polymorphicthis
type, which is an implicitly generic type constrained to the current class instance type. And since theParameters
utility type is implemented as a conditional type, it means thatargs
is of typeParameters<this["sayMessage"]>
, a generic conditional type. The compiler has no idea what values might or might not be assignable to such a type because it tends to just defer evaluation. Until it knows exactly whatthis
is, it doesn’t hazard a guess as to whatParameters<this["sayMessage"]>
might be. And so it fails.It’s much easier on the compiler if you give it values of the exact type it’s supposed to infer. So instead of
args
being some complicated thing, have it be its own generic type parameterA
, and try to rewrite other things in terms of it. For example:This is very similar to yours, except now we only have two generic type parameters. There’s
A
, constrained to a list type, andK
, corresponding to yourMethodKey
, constrained toPropertyKey
which is just a utility type equivalent to "keylike" types,string | number | symbol
.And then for the
this
parameter we say that it is of typeRecord<K, (...args: NoInfer<A>) => void>
. This is essentially just "something with a property of keyK
which is a function accepting an arg list of typeA
" except we don’t really want the compiler to try to inferA
fromthis
. It doesn’t work out. So we use the newNoInfer
utility type introduced in TypeScript 5.4 to tell the compiler not to try. (There are workarounds for 5.3 and below, but I’m not worried about them).And now when you call it, it works. The compiler is happy to see that
this
is of a type like{sayMessage: (...args: string)=>void}
, and it doesn’t need to try to deal with generic conditional types at all:Playground link to code