Reputation: 474
In my codebase, I have a set of "components" which inherit from a single class, called Base
. I have a class called Manager
that is responsible for looking up these "component"'s methods and calling them. Manager
maintains a list of these components and whether they are started or stopped, and only calls the method if the component is started. For example, say I have a component A
which has a method foo
defined as foo(str: string, ...nums: number[])
. The call in manager might be:
Manager.call(A, 'foo', 'some args', 1, 2);
Meaning "find component A and call its foo
method with the arguments "some args", 1, and 2 - and return whatever foo
returns".
The method call
is defined very openly due to the fact that the code was previously pure JS. But I would like to improve it with Typescript if possible so that
I have tried lots of things but I get stuck about the arguments. So far I have:
call<C extends typeof Base, M extends keyof InstanceType<C>>(component: C, method: M, ...args: any) {
return (component as any)[method](args);
}
Which compiles fine, except it does no validation of arguments. I know you can use the Parameters<>
class but I have been unable to get it to work with these generics (tried C[M]
, component[method]
, etc.).
Upvotes: 2
Views: 477
Reputation: 53290
You were indeed on the right track with Parameters
built-in TypeScript utility type, but the issue is to ensure C[M]
is indeed a method, because keyof InstanceType<C>
may also contain "normal" (non callable) property keys.
For all below examples, let's assume we have the following Base
and A
classes:
class Base {
b = 0;
bar() { }
}
class A extends Base {
a = false;
foo(str: string, ...nums: number[]) { }
}
To do so, let's use a few custom helper types:
KeepCallable
using a conditional type to get a resulting R
type if input V
is callable, or a never N
type otherwise:// Helper type to get R if V is callable, N otherwise
// Typically to get `never` if V is not callable
type KeepCallable<V, R = V, N = never> = V extends (...args: any) => any ? R : N;
type t1 = KeepCallable<typeof parseInt>;
// ^? (typeof parseInt)
type t2 = KeepCallable<string>;
// ^? never
MethodParams
to get the Parameters types of a class method, leveraging the previous KeepCallable
helper:// Helper type to extract Parameters types of a class method
type MethodParams<
C extends { new(...args: any): any }, // Must be a class (i.e. have a constructor)
M extends keyof InstanceType<C> // Must be a key of a class instance (but we do not know yet if it is a method or not)
> = KeepCallable<InstanceType<C>[M], Parameters<InstanceType<C>[M]>>; // Get the Parameters in case it is callable
type t4 = MethodParams<typeof A, 'foo'>;
// ^? [str: string, ...nums: number[]]
type t5 = MethodParams<typeof A, 'bar'>;
// ^? []
type t6 = MethodParams<typeof A, 'b'>;
// ^? never
Now we can use MethodParams
helper to properly type the arguments, based on the class and method name:
class Manager {
static call<
C extends typeof Base,
M extends keyof InstanceType<C>
>(component: C, method: M, ...args: MethodParams<C, M>) {
return (component as any)[method](args);
}
}
Manager.call(A, 'foo', 'some args', 1, 2); // Okay
// Case of incorrect rest argument type:
Manager.call(A, 'foo', 'some args', '1'); // Error: Argument of type 'string' is not assignable to parameter of type 'number'.
// ~~~
// Case of incorrect argument type:
Manager.call(A, 'foo', 1); // Error: Argument of type 'number' is not assignable to parameter of type 'string'.
// ~
// Case of missing required argument:
Manager.call(A, 'foo'); // Error: Expected at least 3 arguments, but got 2.
// ~~~~~~~~~~~~~~
// Case of method with no expected argument:
Manager.call(A, 'bar'); // Okay
// Case of extraneous argument:
Manager.call(A, 'bar', 0); // Error: Expected 2 arguments, but got 3.
// ~
But we can still call it with a property name that is not callable; in that case, as seen above, MethodParams
returns never
, but if we do not provide any extra arguments, TypeScript does not see any issue:
// Case of non-method key:
Manager.call(A, 'b'); // Should not have been okay!
For that we can build another custom helper type:
MethodsOf
similar to keyof
, but keeping only keys which associated value is callable, using key remapping and leveraging KeepCallable
again:// Helper type to extract method keys
type MethodsOf<CI extends object> = keyof {
// Remap keys to keep those which value is callable (a method)
[Key in keyof CI as KeepCallable<CI[Key], Key>]: any;
}
type t3 = MethodsOf<A>;
// ^? "foo" | "bar"
And now we can better constrain the M
generic type parameter, so that the call raises a static error if we try to use it with a non callable property:
class Manager {
static call<
C extends typeof Base,
M extends MethodsOf<InstanceType<C>> // Instead of keyof
>(component: C, method: M, ...args: MethodParams<C, M>) {
return (component as any)[method](args);
}
}
// Case of non-method key:
Manager.call(A, 'b'); // Error: Argument of type '"b"' is not assignable to parameter of type '"foo" | "bar"'.
// ~~~
Upvotes: 1