Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do you do an exhaustive switch case in Typescript with string enums that refer to other string enums

Tags:

typescript

I have a string enum that is a subset of the values from another string enum. I am trying to do an exhaustive switch case across the former.

This is a contrived example of the construct I am attempting to represent in Typescript

const enum Color {
    BLUE = 'Blue',
    YELLOW = 'Yellow',
    RED = 'Red'
}

const enum SupportedColor {
    BLUE = Color.BLUE,
    YELLOW = Color.YELLOW
}


function doSomething(colorFromOutsideInput: string): boolean {
    const supportedColor: SupportedColor = (colorFromOutsideInput as unknown) as SupportedColor;
    switch (supportedColor) {
        case SupportedColor.BLUE:
            return true;
        case SupportedColor.YELLOW:
            return true;
        default:
            return invalidColor(supportedColor);
    }
}

function invalidColor(supportedColor: never): never {
    throw new Error();
}

In this code, the default in the switch case fails compilation with

Argument of type 'SupportedColor' is not assignable to parameter of type 'never'.

If I were to do this switch case across the Color enum instead of SupportedColor enum, it would work.

Based on reading in the typescript docs, I see no evidence of why the compiler treats the two enums differently because they evaluate to the same strings.

like image 388
Chandler Gonzales Avatar asked Mar 10 '20 02:03

Chandler Gonzales


People also ask

Why use enums in typescript?

Using enums can make it easier to document intent, or create a set of distinct cases. TypeScript provides both numeric and string-based enums. We’ll first start off with numeric enums, which are probably more familiar if you’re coming from other languages. An enum can be defined using the enum keyword.

Can you write an exhaustive switch statement in typescript?

While having to update switch statements can often indicate code that isn't so great, typescript contains a language construct to be able to write an exhaustive switch statement, though the use of the never type. Now lets look at how we can apply this knowledge.

What is switch enum case and string and case statements?

Here is an example for switch enum case example. String and case statements accepts of variable and data of string type. The type of the value should be primitive string, not global object String type

How to switch on enum value before returning something else?

Create a reusable function that takes an enum value as a parameter. Use a switch statement and switch on the provided value. Return a specific value from each branch. We created a reusable function that takes an enum value as a parameter, switches on the enum value before returning something else. The default case is a matter of implementation.


1 Answers

I'm going to minimize the example a bit but the issues raised are the same.


Enums where each enum value is of an explicit string literal or numeric literal type act like union types, which can be narrowed via type guards. This sort of enum is called a union enum:

// union enum
const enum SupportedColor {
    BLUE = "Blue",
    YELLOW = "Yellow"
}

// no error here
function doSomething(supportedColor: SupportedColor): boolean {
    switch (supportedColor) {
        case SupportedColor.BLUE:
            supportedColor // SupportedColor.BLUE
            return true;
        case SupportedColor.YELLOW:
            supportedColor // SupportedColor.YELLOW
            return true;
    }
    //supportedColor // never
    //~~~~~~~~~~~~~ <-- unreachable code
}

In the above code, the compiler recognizes that the switch statement is exhaustive because supportedColor of type SupportedColor can be narrowed by control flow anlaysis. The type SupportedColor is equivalent to the union SupportedColor.BLUE | SupportedColor.YELLOW.

So inside the various cases, supportedColor is narrowed in turn to the type SupportedColor.BLUE and SupportedColor.YELLOW. After the switch statement is over, the compiler knows that supportedColor is of type never, since both SupportedColor.BLUE and SupportedColor.YELLOW have been filtered out of the union. If you uncomment the code after the switch block, the compiler will even complain that it is unreachable in TS3.7+.

Therefore all code paths return a value and the compiler does not complain.


Now consider what happens when you change the enum values to be calculated instead of literals:

const enum Color {
    BLUE = 'Blue',
    YELLOW = 'Yellow',
    RED = 'Red'
}

// calculated enum
const enum SupportedColor {
    BLUE = Color.BLUE,
    YELLOW = Color.YELLOW
}

function doSomething(supportedColor: SupportedColor): boolean { // error!
    // -------------------------------------------->  ~~~~~~~
    // function lacks ending return statement
    switch (supportedColor) {
        case SupportedColor.BLUE:
            supportedColor // SupportedColor
            return true;
        case SupportedColor.YELLOW:
            supportedColor // SupportedColor
            return true;
    }
    supportedColor // SupportedColor
}

Here the enum type SupportedColor is no longer considered to be a union type. Calculated enums are not union enums. And therefore the compiler has no way to narrow or filter the type of supportedColor in or after the switch statement. The type stays SupportedColor the whole way through. And the switch statement isn't seen to be exhaustive, and the compiler complains that the function doesn't always return a value.


So that explains what's going on, and the documentation link for union enums does say that union enums and calculated enums are different. So it's documented. But what isn't explained in the documentation is why calculated enums are not treated as unions. That is: it's working as designed, but does anyone actually prefer it to be this way, or is it just a design limitation?

The closest thing I can find to a canonical answer is the GitHub issue microsoft/TypeScript#22709 referencing some meeting notes inside microsoft/TypeScript#26241. These notes are:

  • There are actually many kinds of enums under the hood
  • Union enums only get created when only bare literals (or nothing) are initializers
  • These kinds of enums behave differently
  • You might want one or the other and this is how you choose
  • Working As Intended; would be a Breaking Change

So the TS team says that non-union enums are actually sometimes desirable and that making a change here would break existing code which relies on the current behavior. It's not particularly satisfying of an answer since it doesn't describe the use cases in which people want non-union enums, but it's an answer nonetheless: this is intentional. If you want union enums, use literals and not calculated values.


Okay, hope that helps; good luck!

Playground link to code

like image 97
jcalz Avatar answered Oct 06 '22 12:10

jcalz