How do I get TypeScript lookup types to work in this situation?

Viewed 596

Sorry for the unmotivated title, but I really have no clue what else to call it.

I have an enum, and I want to match each of the enum's entries to a type. I'm doing this so that parameters of functions can change dynamically based on the first parameter. An example:

enum Enum {
    A,
    B,
    C
}

interface TypeMap {
    [Enum.A]: number,
    [Enum.B]: string,
    [Enum.C]: boolean
}

function doSomething<K extends Enum>(a: K, b: TypeMap[K]) {
    //
}

The type system works correctly when calling doSomething, for example: doSomething(Enum.A, 5) works, but doSomething(Enum.A, "hello") doesn't. However, what I can't get to work is this:

function doSomething<K extends Enum>(a: K, b: TypeMap[K]) {
    if (a === Enum.A) {
        let num: number = b;
    }
}

Typescript errors out at the assignment to num, but clearly, CLEARLY, it should work. If a is Enum.A, then b has to BY DEFINITION be number, right? What am I doing wrong? How do I get this to work?

2 Answers

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

I may have found a solution using type predicates. In alternative to the classical way of doing type checking (a === Enum.A) we can define a function in which we pass both A and B and narrow the type of B with respect the value of A by using type predicates (basically the keyword is). Like so:

function isA(a: Enum, b: any): b is TypeMap[Enum.A] {
  return a === Enum.A
}

//function isB
//function isC

function doSomething<K extends Enum>(a: K, b: TypeMap[K]) {
  if (isA(a, b)) {
      let x: number = b; 
  }
}

By doing so we can let the compiler successfully narrow the type of B.

You can check this out at this link.

Related