Reputation: 1288
Imagine I have the following code:
const selectors = {
posts: {
getAllPosts(): Post[] {
return database.findPosts();
},
getPostsWithStatus(status: Post["status"]) {
return database.findPosts({ status });
}
},
users: {
getCurrentUser(): User {
return database.findUser();
}
}
};
I want to create a function to call one of the nested methods so I can do something like:
const deletedPosts = select("posts", "getPostsWithStatus", ["deleted"]);
So far here is my implementation:
type Selectors = typeof selectors;
function select<
SelectorType extends keyof Selectors,
SelectorFn extends keyof Selectors[SelectorType]
>(
selectorType: SelectorType,
selectorFn: SelectorFn,
args: Parameters<Selectors[SelectorType][SelectorFn]>
): ReturnType<Selectors[SelectorType][SelectorFn]> {
return selectors[selectorType][selectorFn](...args);
}
The types are properly inferred when calling the function but the implementation itself raises errors:
args: Parameters<Selectors[SelectorType][SelectorFn]>
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Type '{ posts: { getAllPosts(): Post[]; getPostsWithStatus(status: "deleted" | undefined): never[]; }; users: { getCurrentUser(): User; }; }[SelectorType][SelectorFn]' does not satisfy the constraint '(...args: any) => any'.
): ReturnType<Selectors[SelectorType][SelectorFn]> {
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Type '{ posts: { getAllPosts(): Post[]; getPostsWithStatus(status: "deleted" | undefined): never[]; }; users: { getCurrentUser(): User; }; }[SelectorType][SelectorFn]' does not satisfy the constraint '(...args: any) => any'.
return selectors[selectorType][selectorFn](...args);
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This expression is not callable.
So my question is, how can I create a function that dynamically calls another one nested in an object while preserving the types?
Upvotes: 3
Views: 1152
Reputation: 328272
You are running into a few limitations and pain points of TypeScript, and the best way to deal with it is probably to work around them.
The first is a known bug, filed at microsoft/TypeScript#21760. The compiler isn't able to follow the constraints of nested indexed accesses where the key type is generic. I'm not sure if or when this will be fixed.
So, if K extends keyof Selectors
and P extends keyof Selectors[K]
, then the compiler has forgotten that Selectors[K][P] extends (...args: any) => any
, so you can't pass it to the Parameters<T>
utility type or the ReturnType<T>
utility type, whose type parameter is constrained to that function type.
Instead, we can write our own unconstrained versions of Parameters<T>
and ReturnType<T>
. The implementation of Parameters<T>
is like T extends (...args: infer P) => any ? P : never
, so we can just use that directly (but I'll change P
to A
):
function select<
K extends keyof Selectors,
P extends keyof Selectors[K]
>(
selectorType: K,
selectorFn: P,
args: Selectors[K][P] extends ((...args: infer A) => any) ? A : never
): Selectors[K][P] extends (...args: any) => infer R ? R : never {
return selectors[selectorType][selectorFn](...args); // still error
}
const deletedPosts: Post[] = select("posts", "getPostsWithStatus", ["deleted"]);
Now the call signature of select()
has no errors and you can still call it. But the implementation still has an error, which brings us to our second pain point.
This is a general type of limitation, filed as microsoft/TypeScript#30581. There is a correlation between the types of selectors[selectorType][selectorFn]
and the type of args
, but the compiler is unable to follow that. The compiler is unable to perform arbitrary analysis on types, and it does not know that a function f
or type F
and a value a
of type Parameters<F>
can be called like f(...a)
if F
is generic, or if it is a union of function types. The compiler doesn't do a case-by-case analysis of every possible K
and P
type parameter. For every possible call to select()
, the line selectors[selectorType][selectorFn](...args)
is valid, but the compiler does not analyze that line multiple times. It does so once, and it is not seen as type safe because it's too complex.
There is a fix at microsoft/TypeScript#47109 that allows you to refactor code to represent these correlations as generic operations on some base type. But I can't get this to work for your code, probably because of the same nested generic index problem from before.
So instead of trying to get the compiler to see the correlation, we will just take the responsibility away from the compiler and use a type assertion, like this:
return (selectors[selectorType][selectorFn] as any)(...args);
Using the any
type like this is often overkill, but the "correct" assertion is a complicated mess and doesn't really provide any type safety. No matter what you assert, remember that type assertions are a way for you to tell the compiler not to worry about something it can't figure out... and therefore you should be sure you have figured it out. Double and triple check that selectors[selectorType][selectorFn](...args)
is always safe before asserting.
Upvotes: 5