The main issue here is that control flow analysis only works to narrow the types of values of union type. It does not result in the narrowing of generic type parameters that extend union types. Just because you've tested a, of type K extends Enum, it doesn't narrow K itself. There's an open issue in GitHub about this: microsoft/TypeScript#24085.
One problem with narrowing K when checking a is that nothing stops K from being the full union type Enum. For example:
function getEnum(): Enum { return Enum.A };
If I call getEnum() I will definitely get Enum.A at runtime, but the compiler only sees the return type as Enum, the full union type. And therefore if you call doSomething(), the compiler allows this:
doSomething(getEnum(), "oops"); // no error!
Oops. So in fact the error in the implementation of doSomething() is actually warning you of a real (if uncommon) issue: K could be Enum, a could be Enum.A, and b could be something other than number.
If you could tell the compiler that K is restricted to being exactly one member of the Enum union, then it would be safer to do the narrowing. Right now there's no way to express that sort of generic constraint, but there's an open issue asking for it: microsoft/TypeScript#27808.
For now then you'll have to work around this by giving up some compiler-guaranteed type safety, such as using type assertions:
function doSomethingAssert<K extends Enum>(a: K, b: TypeMap[K]) {
if (a === Enum.A) {
let num = b as number; // assert here
} else if (a === Enum.B) {
let str = b as string; // assert here
}
}
This is probably the least disruptive solution for you. You could other workarounds like the user defined type guard in the other answer, but it has the same lack of type safety (nothing prevents you from writing let num = b as string, and nothing prevents you from writing isA(a, "oops") in the type guard. So it's up to you which flavor of unsoundness you like better.
One last idea: maybe you'd consider refactoring your data to use a discriminated union instead of a pair of function parameters? And have it no longer generic? The compiler is much better about using control flow analysis on discriminated union objects. So you'd package a and b into a single object type, like this:
type DiscrimUnion = { [K in Enum]: { a: K, b: TypeMap[K] } }[Enum]
// type DiscrimUnion = { a: Enum.A; b: number;} | { a: Enum.B; b: string;} |
// { a: Enum.C; b: boolean;}
And then the implementation works the way you want:
function doSomethingDiscrimUnion(u: DiscrimUnion) {
if (u.a === Enum.A) {
let num: number = u.b;
} else if (u.a === Enum.B) {
let str: string = u.b;
}
}
and has better guarantees about how you can call it:
doSomethingDiscrimUnion({a: Enum.A, b: 123}); // okay
doSomethingDiscrimUnion({a: getEnum(), b: "oops"}); // error! not a DiscimUnion
Okay, hope that helps; good luck!
Playground link to code