Basically I'm interested why does this example work:
interface Alpha {}
interface Beta {}
interface HelloMapping {
'Alpha': Alpha
'Beta': Beta
}
const hello = <T extends keyof HelloMapping>(arg: T): HelloMapping[T] =>
{
if (arg === 'Alpha') return {} as Alpha;
return {} as Beta;
};
const a = hello('Alpha') // a is of interface Alpha
But this one doesn't:
interface HelloMapping2 {
'Alpha': string
'Beta': number
}
const hello2 = <T extends keyof HelloMapping2>(arg: T): HelloMapping2[T] =>
{
if (arg === 'Alpha') return "42" as string
return 42 as number
};
It errors with Type 'string' is not assignable to type 'never'
in the first line of function code.
The only difference is changing interfaces to string
and number
types
Please note, I know how to make that work with function overloads but I am curious why it does not work.
The first one works for the reason pointed out by @zerkms: Alpha
and Beta
are the same type. TypeScript's type system is structural, not nominal. If Alpha
and Beta
have the same shape (i.e., the empty type {}
), they are the same type, despite having different names. So let's ignore that example because it rests on an accidental type identity.
The reason the second example won't work is that generics do not get narrowed by control flow analysis (see microsoft/TypeScript#24085). The check arg === 'Alpha'
doesn't convince the compiler that T
is now 'Alpha'
. It's generally unsound to do so; if there are multiple possible values of type T
, then checking one value of type T
doesn't necessarily have implications on any other.
There's currently no clean solution for this, although suggestions have been made that might address it. One suggestion is to allow you to specify that a generic type must be exactly one literal member of a union, so that any values of type T
that exist must be identical, and you can safely narrow all of them when you narrow one.
If you want to see this implemented or the situation addressed, you might want to head over to those GitHub issues and give them a 👍 or describe your use case if it's particularly compelling.
So, what to do in the meantime?
The answer that always works to allow the implementation to compile with out error is to use type assertions or overloads (they are what I call "morally" equivalent, since overloads essentially assert the types of parameters and returns to be the those in the implementation signature). In your case they would look like this (I know you know how to do this, but others who come later might not):
const helloAssert = <T extends keyof HelloMapping>(arg: T): HelloMapping[T] => {
if (arg === 'Alpha') {
return "42" as HelloMapping[T];
}
return 42 as HelloMapping[T];
};
function helloOverload<T extends keyof HelloMapping>(arg: T): HelloMapping[T];
function helloOverload(arg: keyof HelloMapping) {
if (arg === 'Alpha') {
return "42"
}
return 42;
}
These are somewhat unsafe, though, because although they'd catch completely crazy errors (e.g., return true
), they wouldn't catch simple mixups (e.g., change ===
to !==
in the test).
Sometimes, though, you can avoid these unsafe solutions by abandoning control flow analysis and represent the generic type manipulation directly: an object o
of type O
and a key k
of type K
yields a readable property o[k]
of type O[K]
, even if O
and K
are generic:
const helloMap = <T extends keyof HelloMapping>(arg: T): HelloMapping[T] =>
({ Alpha: "42", Beta: 42 })[arg];
So you give an actual HelloMapping
instance to helloMap()
and index into it. This might not work for all use cases, of course, but it's sometimes useful to do things in a way that works with the compiler instead of against it.
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