I have a type with function fields and a utility function to get that type's function by name:
type Person = {
getAge: () => number;
getName: () => string;
};
function getPersonFunction<K extends keyof Person>(person: Person, key: K): Person[K] {
return person[key];
}
Now I want another utility function to execute it dynamically:
function callPersonFunction<K extends keyof Person>(person: Person, key: K) {
return person[key]();
}
But TypeScript infers the return type of this function as any
. I tried to use ReturnType<Person[K]>
to specify the return type, but it results in an error.
How can I properly call the function, infer its return type dynamically, and keep it generic?
Updated: Additional requirements:
The main problem here is that TypeScript lacks the ability to look at an object type like Person
and verify or deduce a higher order relationship like "for arbitrary K extends keyof Person
, if Person[K]
is a function, then Person[K]
is equivalent to (...args: Parameters<Person[K]>) => ReturnType<Person[K]>
. That might seem obvious to you, but you know what Parameters<F>
and ReturnType<F>
mean for generic F
and TypeScript doesn't. It can evaluate such types for any specific F
or K
, but for the arbitrary generic case, TypeScript mostly just gives up.
The solution is to refactor your types so that the relationship you care about is represented explicitly in the type, by writing them as mapped types and using generic indexes into those types. This is described in detail in microsoft/TypeScript#47109.
For your example as written, this involves refactoring Person
into a mapped type over a "base" interface:
interface PersonRet {
getAge: number;
getName: string;
}
type Person = { [K in keyof PersonRet]: () => PersonRet[K] }
And then your callPersonFunction
will just work, because its return type is inferred as PersonRet[K]
:
function callPersonFunction<K extends keyof Person>(person: Person, key: K) {
return person[key]();
}
For the general case where your functions might have input parameters and where some properties are not functions, you can write utility types to convert your type into a base parameter type and a base return type mapping:
type ParamMap<T> = { [K in keyof T as T[K] extends (...args: any) => any ? K : never]:
T[K] extends (...args: infer A) => any ? A : never }
type ReturnMap<T> = { [K in keyof T as T[K] extends (...args: any) => any ? K : never]:
T[K] extends (...args: any) => infer R ? R : never }
And when you use it, you evaluate these base mapping types:
class Foo {
a: string = ""
b: number = 123;
c() { return this.a }
d(x: number) { return this.b + x }
}
type FooParams = ParamMap<Foo>
// type FooParams = { c: []; d: [x: number]; }
type FooReturn = ReturnMap<Foo>
// type FooReturn = { c: string; d: number; }
You can see that only the keys corresponding to methods are present, and the property values are the parameter list types and the return types, respectively. Now you can rewrite Foo
as a mapped type that just contains the methods:
type FooMethods = { [K in keyof FooParams]:
(...args: FooParams[K]) => FooReturn[K] };
/* type FooMethods = {
c: () => string;
d: (x: number) => number;
} */
The FooMethods
type is a supertype of Foo
, and importantly, it is explicitly represented as a mapped type over FooParams
and FooReturn
, so your generic calling function looks like:
function callFooFunction<K extends keyof FooParams>(
foo: Foo, k: K, ...args: FooParams[K]
): FooReturn[K] {
const fooMethods: FooMethods = foo;
return fooMethods[k](...args)
}
This works because we explicitly widen foo
from Foo
to FooMethods
. When we index into FooMethods
with K
we get the generic single function type (...args: FooParams[K]) => FooReturn[K]
, so it is callable with an argument list of type FooParams[K]
and returns FooReturn[K]
as desired.
Let's verify that it behaves as advertised:
callFooFunction(new Foo(), "c").toUpperCase()
callFooFunction(new Foo(), "d", 123).toFixed()
Looks good.