Safer Exhaustive Switch Statements in TypeScript

published on 29 December 2022

In this blog we will discuss how exhaustive type checking can help us write safer switch statements, as well as some simple techniques using TypeScript and ESlint rules that can help catch mistakes in our switch (and even if/else) statements at compile time.

Motivation

Let's consider a simple example of a switch statement to return the list of permissions for different user types who may access a document. Owners should have the full set of permissions, Viewers can only have view permissions, while unauthorised users have no permissions.

(Note that in this case we are using an enum for our UserType but a union of string literals would work equally well.)

enum UserType {
    Owner = "Owner",
    Viewer = "Viewer",
    Unauthorised = "Unauthorised"
}
 
enum PermissionType {
    Delete = "Delete",
    Edit = "Edit",
    View = "View",
}
 
function getUserPermissions(userType: UserType) {
    switch(userType) {
        case UserType.Owner:
            return [
                PermissionType.Delete,
                PermissionType.Edit,
                PermissionType.View
            ];
        case UserType.Viewer:
            return [
                PermissionType.View
            ];
        case UserType.Unauthorised:
            return [];
    }
}

This switch statement currently works as expected, however what would happen if a new value is added to the UserType enum? For example an Editor type that should have view and edit permissions:

enum UserType {
    Owner = "Owner",
    Editor = "Editor", // New enum member
    Viewer = "Viewer",
    Unauthorised = "Unauthorised"
}
 
// Unhandled case - returns undefined
getUserPermissions(UserType.Editor);

Now our switch statement above would need correcting, by adding a case UserType.Editor which returns [PermissionType.Edit, PermissionType.View].

Broadly speaking, such changes to UserType would mean we must remember to manually review all switch statements across our codebase that depend on a UserType parameter.

We could of course add a default case (see below), but that on its own would not tell us automatically that we have forgotten to handle a new case - case UserType.Editor.

function getUserPermissions(userType: UserType) {
    switch(userType) {
        //
        // ... Other cases here ...
        //
        default:
            throw new Error("Error - Unexpected default case!");
    }
}

Ideally, we would like to catch these mistakes early automatically (at compile time) and be sure that our switch statements are exhaustive i.e. include a case clause for every enum member type.

The exhaustive guard function

One easy way of achieving this is with an exhaustive guard function:

function exhaustiveGuard(_value: never): never {
    throw new Error(`ERROR! Reached forbidden guard function with unexpected value: ${JSON.stringify(_value)}`);
}

Notice how we are using the never TypeScript type here for both the input parameter and return type. This will inform the TypeScript compiler that no argument should ever be passed to this guard function, and that the function should never return a value.

We can now include exhaustiveGuard as the default case for our earlier switch statement:

function getUserPermissions(userType: UserType) {
    switch(userType) {
        case UserType.Owner:
            return [
                PermissionType.Delete,
                PermissionType.Edit,
                PermissionType.View
            ];
        case UserType.Viewer:
            return [
                PermissionType.View
            ];
        case UserType.Unauthorised:
            return [];
        default:
    		// We should never reach this default case
            return exhaustiveGuard(userType);
    }
}

(Note that never is a subtype of Array<PermissionType>, so calling return exhaustiveGuard will not cause compile errors here.)

The TypeScript compiler will fail if it can find a logical path through our code that may call exhaustiveGuard. In other words, if we have not included a case for each UserType, we can logically reach the default case and therefore call exhaustiveGuard - leading to a TypeScript error.

Exhaustive switch guard in action

After applying the exhaustive guard function to the initial switch statement we get the following error in the IDE:

image-of-an-error-in-IDE-after-applying-exhaustive-guard-function-c5f2d

As explained above, the error indicates that we have not handled the full range of types and may pass a userType of UserType.Editor to exhaustiveGuard. After adding the missing Editor case our code will compile:

function getUserPermissions(userType: UserType) {
    switch(userType) {
        case UserType.Owner:
            return [
                PermissionType.Delete,
                PermissionType.Edit,
                PermissionType.View
            ];
        case UserType.Editor:
            return [
                PermissionType.Edit,
                PermissionType.View
            ];
        case UserType.Viewer:
            return [
                PermissionType.View
            ];
        case UserType.Unauthorised:
            return [];
        default:
            return exhaustiveGuard(userType);
    }
}

Naturally, the same exhaustiveGuard function can be declared once and reused in other situations across your codebase. For example in if/else statements if there are logical branches that should never be reached:

image-of-an-IDE-after-applying-exhaustive-guard-function-nqfiu

Overall, using an exhaustive guard function like this is a simple, versatile pattern to consider for branching logic consuming enumerated or union types, and is suggested in the official TS handbook.

However, remembering to use such guard functions may not always be practical. So it is worth considering alternative solutions as well.

Alternative 1 - Exhaustiveness ESlint rule

One alternative is to enable the switch-exhaustiveness-check ESlint rule, which similarly highlights non-exhaustive switch statements and which cases they are missing (as shown below):

image-of-an-IDE-using-ESlint-rule-9c1ab

Using a linting rule has a number of benefits:

  • Automatic - ESlint rules can run automatically as you build/save code, meaning that you (and your team) do not need to adopt any new patterns or guard functions.
  • Can be a Warning - Unlike the exhaustiveGuard, you can control whether these non-exhaustive switch statements should be flagged as just a warning, or a blocking error.
  • Quick Fix - You can even add the missing switch cases with the “Quick Fix” option directly in your IDE.

However, it does have limitations:

  • Does not work for if statements - switch-exhaustiveness-check is specifically for switch statements and will not work for the if/else example mentioned earlier.
  • Does not throw informative errors at runtime - ESlint rules are intended for compile/build time checks. So if your switch statement receives an unexpected value at runtime (e.g. an invalid string like “ADMIN” or undefined instead of a UserType) you may not emit an informative error and may just return undefined from your switch statement.

Alternative 2 - noImplicitReturns compiler option

Another alternative to the exhaustive guard function is to enable the noImplicitReturns TypeScript compiler option. TypeScript will then verify that all code paths in every function will return a value. This can be useful in general to check that you have not forgotten to write an explicit return statement for each logical branch across your switch or if/else statements, for example:

image-of-an-IDE-when-noImplicitReturns-compiler-is-enabled-c0ji9

But there are a couple of drawbacks to consider:

  • This won’t work if the switch or if/else statement explicitly returns a void type i.e. only calls functions without returning any values.
  • Unlike the other options above, this approach may only give a general compilation error, without identifying the missing case clause (see below):
image-of-an-IDE-with-a-general-compilation-error-mvzpc

So as we have shown, each of the above solutions comes with different benefits. So it is worth considering if one (or all three) may be suited for your project or code style.

Conclusion

In this blog, we have compared a few ways to use TypeScript type checks and ESlint rules to ensure that our switch statements always include a case for each enumerated option at compile-time.

We should now be more confident in changing the underlying types used by our switch or (if/else) logic and quicker to make corrections if the branching cases are not exhaustive.

If you are interested in learning more about writing robust and well-tested code you may find a range of relevant blogs from Meticulous, including on React Error Boundaries and Frontend Unit Testing Best Practices.

Thank you for reading!

Meticulous

Meticulous is a tool for software engineers to catch visual regressions in web applications without writing or maintaining UI tests.

Inject the Meticulous snippet onto production or staging and dev environments. This snippet records user sessions by collecting clickstream and network data. When you post a pull request, Meticulous selects a subset of recorded sessions which are relevant and simulates these against the frontend of your application. Meticulous takes screenshots at key points and detects any visual differences. It posts those diffs in a comment for you to inspect in a few seconds. Meticulous automatically updates the baseline images after you merge your PR. This eliminates the setup and maintenance burden of UI testing. Like self-writing UI tests, but without the maintenance burden.

Meticulous isolates the frontend code by mocking out all network calls, using the previously recorded network responses. This means Meticulous never causes side effects.

Learn more here.

Authored by Alex Langdon

Read more