Search code examples
javascripttypescriptswitch-statementunion-types

Variable of union type causes error in switch statement


Consider we have a union type that represents one of three different string values.

type Animal = 'bird' | 'cat' | 'dog';

Now I would like to create a dog and check what kind of animal it is to create the correct noise it performs.

let oscar: Animal = 'dog';

switch (oscar) {
  case 'bird':
    console.log('tweet');
    break;
  case 'cat':
    console.log('meow');
    break;
  case 'dog':
    console.log('bark');
    break;
}

This code will result in a TypeScript error: Type '"bird"' is not comparable to type '"dog"'.ts(2678) (analog with cat). However, if I use an explicit type cast on the variable oscar, it works without problems:

switch (oscar as Animal) {
  case 'bird':
    ...
  case 'cat':
    ...
  case 'dog':
    ...
}

Can you please explain to me why the first two switch statements fail if I use an explicit value for oscar?

I could understand the error if I declared Oscar as a constant: const oscar = 'dog';, because in that case it would always be a dog and nothing else. However, just imagine for a moment that Oscar could become a cat if a wizard would perform a certain spell:

let oscar: Animal = 'dog';

while(true) {
  switch (oscar) {
  case 'bird':
    ...
  case 'cat':
    ...
  case 'dog':
    console.log('bark');

    // here comes the wizard
    if(wizard.performsSpell('makeOscarBecomeACat')) {
      oscar = 'cat';  // that should be valid, because oscar is of type Animal
    }

    break;
  }
}

Do I misunderstand something about the assignment of the variable oscar, or is this simply a TypeScript bug?


Solution

  • What you might be misunderstanding is that TypeScript 2.0 and above has a feature called control-flow based type analysis, implemented in microsoft/TypeScript#8010. One of the effects of this feature is that

    An assignment (including an initializer in a declaration) of a value of type S to a variable of type T changes the type of that variable to T narrowed by S in the code path that follows the assignment. [...] The type T narrowed by S is computed as follows: [...] If T is a union type, the result is the union of each constituent type in T to which S is assignable.

    That means the statement

    let oscar: Animal = 'dog';
    

    is interpreted as: "the variable oscar has the type Animal, a union type. It has been assigned a value of the string literal type "dog", so until it gets reassigned, we will treat the variable oscar as the type Animal narrowed by "dog", which is just "dog".

    And therefore in your switch/case statement:

    case 'bird': // error!
    //   ~~~~~~ <-- Type '"bird"' is not comparable to type '"dog"'
    

    You get the error about trying to compare a string literal "bird" to a string literal "dog". The compiler knows that the 'bird' case is impossible because you have not reassigned oscar to something compatible with 'bird'.

    Even in your wizard case, the compiler understands that when it reaches the switch/case statement, oscar can only be "cat" or "dog" and not "bird":

    case 'bird': // error! 
    //   ~~~~~~ <-- Type '"bird"' is not comparable to type '"cat" | "dog"'
    

    This is all probably good news; the compiler is catching cases that can never happen. For many situations these are genuine bugs.

    If you want the compiler not to realize that oscar is definitely "dog" and only know that it's an Animal (as, say, a placeholder until you write code that makes it genuinely possible for it to be any member of Animal), you can use a type assertion in the assignment itself:

    let oscar: Animal = 'dog' as Animal;
    

    Now all your other code will compile without error. You can even forget the annotation since it wasn't helping you:

    let oscar = 'dog' as Animal;
    

    Okay, hope that helps; good luck!

    Playground link to code