The main issue at hand is TypeScript's inability to examine an object type such as Person
and confirm or deduce a higher-level relationship like "for any K extends keyof Person
, if Person[K]
is a function, then Person[K]
should be equivalent to
(...args: Parameters<Person[K]>) => ReturnType<Person[K]>
. This concept may appear straightforward to you, but TypeScript fails to grasp what
Parameters<F>
and
ReturnType<F>
signify for the generic types
F
. It can analyze these types for specific instances of
F
or
K
, but when it comes to the abstract
generic scenario, TypeScript falters.
The solution lies in reorganizing your types so that the desired relationship is explicitly articulated within the type itself by using mapped types and leveraging generic indexes into those types. A comprehensive guide on this approach is outlined in microsoft/TypeScript#47109.
To illustrate with the provided example, you need to transform Person
into a mapped type based on a 'base' interface:
interface PersonRet {
getAge: number;
getName: string;
}
type Person = { [K in keyof PersonRet]: () => PersonRet[K] }
Subsequently, your callPersonFunction
will seamlessly function since its return type is automatically inferred as PersonRet[K]
:
function callPersonFunction<K extends keyof Person>(person: Person, key: K) {
return person[key]();
}
For scenarios where functions have input parameters and not all properties are functions, you can devise utility types to convert your type into fundamental parameter type and return type mappings:
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 }
Upon implementation, evaluate these foundational 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; }
Notice that only keys associated with methods are included, and the values represent parameter list types and return types, correspondingly. Hence, you can redefine Foo
as a mapped type solely containing the methods:
type FooMethods = { [K in keyof FooParams]:
(...args: FooParams[K]) => FooReturn[K] };
/* type FooMethods = {
c: () => string;
d: (x: number) => number;
} */
The FooMethods
type supersedes Foo
, explicitly portrayed as a mapped type over FooParams
and FooReturn
. Consequently, your generic calling function appears as follows:
function callFooFunction<K extends keyof FooParams>(
foo: Foo, k: K, ...args: FooParams[K]
): FooReturn[K] {
const fooMethods: FooMethods = foo;
return fooMethods[k](...args)
}
This operates effectively as we deliberately broaden foo
from Foo
to FooMethods
. By indexing into FooMethods
with K
, we acquire the generic single function type
(...args: FooParams[K]) => FooReturn[K]
, making it callable with an argument list of type
FooParams[K]
and yielding
FooReturn[K]
as intended.
You can verify its functionality as advertised:
callFooFunction(new Foo(), "c").toUpperCase()
callFooFunction(new Foo(), "d", 123).toFixed()
Results seem promising.
Visit Playground link for code