I think the general issue is that TypeScript does not narrow a type parameter extending a union via control flow analysis, the way it normally does for values of specific union types. See microsoft/TypeScript#24085 for discussion. You've checked whether key
is "a"
or "b"
, and key
is of type K
, but this has no implications on K
itself. And since the compiler doesn't know that K
is anything narrower than "a" | "b"
, it doesn't know that ClassMap[K]
can be anything wider than A & B
. (Since TypeScript 3.5, writing to a lookup property on a union of keys requires an intersection of properties; see microsoft/TypeScript#30769.)
Technically speaking it is correct for the compiler to refuse to do this narrowing, since nothing stops the type parameter K
from being specified as the full union type "a" | "b"
, even if you check it:
pickSomething(Math.random() < 0.5 ? "a" : "b"); // K is "a" | "b"
Currently there's no way to tell the compiler that you don't really mean K extends "a" | "b"
, but instead something like K extends "a"
or K extends "b"
; that is, not a constraint to a union, but a union of constraints. If you could express that, it would perhaps be possible for checking key
to narrow K
itself and then understand that, for example, ClassMap[K]
is just A
when key
is "a"
. See microsoft/TypeScript#27808 and microsoft/TypeScript#33014 for the relevant feature requests there.
Since those aren't implemented, the easiest way to get your code to compile with the minimum changes is to use type assertions. It's not fully type safe, of course:
const pickSomething = <K extends keyof ClassMap>(key: K): ClassMap[K] => {
switch (key) {
case 'a':
return new A() as A & B
case 'b':
return new B() as A & B
}
throw new Error();
}
but the JavaScript produced is idiomatic, at least.
Other possibilities: the compiler does allow you to return a lookup property type T[K]
by actually looking up a property of key type K
on an object of type T
. You could refactor your code to do this:
const pickSomething = <K extends keyof ClassMap>(key: K): ClassMap[K] => {
return {
a: new A(),
b: new B()
}[key];
}
If you don't want to actually create new A()
and new B()
every time you call pickSomething
, you could use getters instead, so that only the desired code path is actually followed:
const pickSomething = <K extends keyof ClassMap>(key: K): ClassMap[K] => {
return {
get a() { return new A() },
get b() { return new B() }
}[key];
}
This compiles without error and is type safe. But it's weird code, so I don't know if it's worth it. I think a type assertion is, for now, the right way to go. And hopefully at some point, there will be a better solution to microsoft/TypeScript#24085 that makes your original code work without need for the assertion.
Playground link to code