The issue at hand seems to stem from the fact that TypeScript does not narrow a type parameter extending a union through control flow analysis, as it typically would for specific union types. For more insights, refer to the discussion on microsoft/TypeScript#24085. Even though you've checked whether `key` is 'a' or 'b', and `key` is of type `K`, this check does not affect `K` itself. Since the compiler is unaware that `K` is anything more specific than 'a' or 'b', it cannot infer that `ClassMap[K]` could be broader than `A & B`. (Beginning with TypeScript 3.5, modifying a lookup property on a union of keys necessitates an intersection of properties; refer to microsoft/TypeScript/pull/30769.)
From a technical standpoint, it's valid for the compiler to resist narrowing in this scenario since there's nothing preventing the type parameter `K` from being specified as the complete union type 'a' or 'b', even after checking it:
pickSomething(Math.random() < 0.5 ? 'a' : 'b'); // K is 'a' | 'b'
Presently, there isn't a way to communicate to the compiler that you don't intend `K extends 'a' | 'b'`, but rather something like `K extends 'a'` or `K extends 'b'`; in essence, not a constraint to a union, but a union of constraints. If such expression were possible, checking `key` might narrow `K` itself and subsequently understand that, for instance, `ClassMap[K]` equals just `A` when `key` is `a`. Refer to microsoft/TypeScript#27808 and microsoft/TypeScript#33014 for related feature requests.
In lieu of these features, utilizing type assertions provides the most efficient means to compile your code with minimal modifications. Though not entirely type safe:
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();
}
the resultant JavaScript adheres to conventions, at the very least.
Another approach involves leveraging the compiler's ability to return a lookup property type `T[K]` by directly looking up a property of key type `K` on an object of type `T`. This could lead to refactoring your code as follows:
const pickSomething = <K extends keyof ClassMap>(key: K): ClassMap[K] => {
return {
a: new A(),
b: new B()
}[key];
}
If avoiding instantiation of `new A()` and `new B()` each time `pickSomething` is called is desired, consider using getters instead, ensuring only the necessary path is taken:
const pickSomething = <K extends keyof ClassMap>(key: K): ClassMap[K] => {
return {
get a() { return new A() },
get b() { return new B() }
}[key];
}
This compilation proceeds without errors and maintains type safety. However, the unconventional nature of the code raises questions about its value. For now, opting for a type assertion seems the most appropriate route. Hopefully, in due course, an improved solution will address the concerns raised in microsoft/TypeScript#24085, eliminating the need for assertions in your original code.
Playground link to code