Search code examples
typescriptstrong-typing

TypeScript method to wrap any method of another class


I have the following setup in TypeScript:

abstract class Test {
    public abstract method1(param1: string): number;
    public abstract method2(param1: number, param2: string): Promise<number>;
}

class Wow {
    constructor(private test: Test) {}

    public fetch<K extends keyof Test>(key: K, params: Parameters<Test[K]>): ReturnType<Test[K]> {
        return this.test[key](params);
    }
}

But the return line inside the method fetch gives two errors:

Type 'number | Promise' is not assignable to type 'ReturnType<Test[K]>'.
Type 'number' is not assignable to type 'ReturnType<Test[K]>'. (2322)

Expected 2 arguments, but got 1. (2554)

I'm puzzled on why these errors appear and how to solve them.
What I want is for my method fetch to take a specific key of my Test class and be strongly typed with the proper parameters and return value of method specified by the key.


Solution

  • TypeScript can't really do much reasoning about conditional types that depend on generic type parameters. The Parameters<T> and ReturnType<T> utility types are implemented as conditional types, and thus Parameters<Test[K]> and ReturnType<Test[K]> are essentially opaque to the compiler. The best it can do is to widen K to its constraint, keyof Test, and so you end up with a union of method names and a union of parameter lists. And then the compiler gets confused because it can't be sure that this.test[key] actually accepts params as its parameter list, because maybe you're passing the method1 params to method2 or vice versa. This is unlikely to actually happen (as long as K isn't specified with a union), but the compiler can't see that. It has lost track of the correlation between key and params. The general issue here is TypeScript's lack of direct support for what I call "correlated unions", as discussed in microsoft/TypeScript#30581

    The recommended fix for that issue is described in microsoft/TypeScript#47109. The compiler is better about dealing with basic key-value interface types, and generic indexes into such types and mapped types over them.

    For your example, it means that we need to rewrite Test as such a mapped type. Like this:

    type TestParams = { [K in keyof Test]: Parameters<Test[K]> };
    type TestReturn = { [K in keyof Test]: ReturnType<Test[K]> };
    type TestMapped = { [K in keyof Test]:
      (...args: TestParams[K]) => TestReturn[K]
    }
    

    The TestParams and TestReturn types are the "basic key-value interface types", and TestMapped the mapped type over these types. You can see that the type TestMapped is completely equivalent to the type Test, and indeed the compiler will allow you to assign a value of type Test to a variable of type TestMapped.

    declare const t: Test;
    const tM: TestMapped = t; // okay
    

    And now you can rewrite fetch():

    class Wow {
      constructor(private test: Test) { }
    
      public fetch<K extends keyof Test>(
        key: K, params: TestParams[K]): TestReturn[K] {
        const thisTest: TestMapped = this.test;
        return thisTest[key](...params);
      }
    }
    

    Here the params input type and the return type are now written as generic indexes into our basic key-value interface types. Inside the implementation, we assign this.test to a variable thisTest of type TestMapped, which enables the compiler to "see" what we're doing when we call thisTest[key](...params). The type of thisTest[key] is seen to be (...args: TestParams[K]) => TestReturn[K], and the type of params is seen to be TestParams[K], so calling the former with a spread argument list of the latter produces a result of type TestReturn[K], which is the desired output type of the function. So everything works.

    Note that this refactoring caught an error: your code was of the form thisTest[key](params) instead of thisTest[key](...params), meaning you passed the whole params array as the first argument instead of spreading it into multiple arguments. If you left it that way you'd get the compiler error: Argument of type '[TestParams[K]]' is not assignable to parameter of type 'TestParams[K]', which hopefully would be enough information for you to fix the problem.

    Now everything works as desired.

    Playground link to code