Consider:
const fcts = {
fctNumber: () => 1,
fctBoolean: () => true,
}
as expected, a function that simply selects from fcts
returns the correct type based on the value of T
const getFct = <T extends keyof typeof fcts>(name: T) => {
return fcts[name]
}
getFct('fctNumber') // () => number
getFct('fctBoolean') // () => boolean
The same function but returning the result of calling the selected function is expected to return the return type of the selected function, but TS instead infers number | boolean
const getResult = <T extends keyof typeof fcts>(name: T) => {
const fct = fcts[name]
return fct()
}
getResult('fctNumber') // expected number, got number | boolean
getResult('fctBoolean') // expected boolean, got number | boolean
Changing the last line to add what seems like a redundant assertion works:
return fct() as ReturnType<typeof fct>
...but why? that doesn't seem to be adding any new information
TypeScript cannot perform the kind of higher order analysis needed to maintain the correlation between the type of name
(a generic type K
) and the return type of fcts[name]
. It doesn't "see" fcts
as an object of zero-arg functions which returns a type correlated with the type of name
. When evaluating the type (typeof fcts)[K]
as a callable function, the compiler first widens it to its constraint, which is (typeof fcts)[keyof typeof fcts]
, and becomes the union type (() => number) | (() => boolean)
. And once that happens there's no hope, since TypeScript can't do much with correlated union types either. This is the subject of microsoft/TypeScript#30581.
If you don't want to just use a type assertion for expedience, you could try refactoring as recommended in microsoft/TypeScript#47109. What we need is for fcts
to be of a mapped type like { [K in keyof FctsReturn]: () => FctsReturn[K] }
for an appropriately-defined FctsReturn
. Then fcts[name]
would be a single function of type () => FctsReturn[K]
, and calling it would result in FctsReturn[K]
. No union involved.
You can use your existing code to derive FctsReturn
, such as by renaming fcts
out of the way (to, say, _fcts
), computing FctsReturn
using the ReturnType<T>
utility type, and then giving fcts
the necessary type annotation:
const _fcts = {
fctNumber: () => 1,
fctBoolean: () => true,
}
type FctsReturn = { [K in keyof typeof _fcts]: ReturnType<typeof _fcts[K]> };
const fcts: { [K in keyof FctsReturn]: () => FctsReturn[K] } = _fcts;
This looks like a roundabout way of doing nothing; surely the type of fcts
is equivalent either way. But since the compiler cannot automatically "see" how to distribute ReturnType<T>
across the elements of fcts
, you have to do it yourself. Now this works:
const getResult = <K extends keyof FctsReturn>(name: K) => {
const fct = fcts[name]
return fct()
}
const num = getResult('fctNumber');
// ^? const num: number
const boo = getResult('fctBoolean');
// ^? const boo: boolean
Playground link to code
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With