kjsmita6
kjsmita6

Reputation: 474

Typescript method signature to lookup class/method by name and validate

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

  1. The method name is validated at compile time to ensure it exists
  2. The number and types of arguments are validated to ensure they are correct
  3. The return type of the called method is validated

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

Answers (1)

ghybs
ghybs

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:

  1. 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
  1. 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:

  1. 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"'.
//              ~~~

Playground Link

Upvotes: 1

Related Questions