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