TypeScript: Use Satisfies for Exhaustive Type Checks

A common case (pun intended) when working with enums (or any set of known keys) in TypeScript is to check for a variable value in a switch statement:

enum Animal {
  Cat,
  Dog,
}

function makeSound(animal: Animal) {
  switch (animal) {
    case Animal.Cat: console.log('meow'); break;
    case Animal.Dog: console.log('bark'); break;
  }
}

Let's say we want to add a new value to the enum. There is a slight chance that we forget to adapt the switch-case implementation. However, we will never notice that since the code will compile just fine.

enum Animal {
  Cat,
  Dog,
  Bird, // ADDED, no compile error
}

Check Exhaustiveness

We need a way to check if our implementation is exhaustive. The obvious solution is to throw an error. However, the error will only be reported during runtime.

function makeSound(animal: Animal) {
  switch (animal) {
    case Animal.Cat: console.log('meow'); break;
    case Animal.Dog: console.log('bark'); break;
    default: throw new Error('coding mistake, oops'); // DON'T DO THIS
  }
}

The right way to do it is to use the satisfies operator, which was introduced in TypeScript 4.9 some time ago. This way, you will get a compile-time error.

function makeSound(animal: Animal) {
  switch (animal) {
    case Animal.Cat: console.log('meow'); break;
    case Animal.Dog: console.log('bark'); break;
    default: animal satisfies never; // DO THIS
  }
}

See in TS Playground.

Explanation: satisfies never works because TypeScript can infer what possible types a variable can still have after the case checks. If we handled every case, the variable can only have the type never. In our example animal can still have the type Animal.Bird. Therefore, it throws a compile time error since Animal.Bird does not satisfy never. Nothing ever satisfies never (except never).

If your function has a non-void return value, you can just write return x satisfies never.

function makeSound(animal: Animal): string {
  switch (animal) {
    case Animal.Cat: return 'meow';
    case Animal.Dog: return 'bark';
    default: return animal satisfies never; // DO THIS
  }
}

Hint: Your switch will act exhaustively out of the box when you manually specify a non-void return type.

Do Runtime + Compile-Time Checks if You Don't Trust Your Types

If you don't trust your types, satisfies never will not help you. In this case, you should combine compile-time checks and runtime checks. For instance, with a function like:

function exhaustiveCheck(x: never): never {
  throw new Error("Exhaustive check failed");
}

Alternatively, TS-Essentials exports a UnreachableCaseError class which does exactly that.

However, you should make sure you can trust your types in the first place. The main reason you might not trust them is that you use data that originates from an API call you made earlier. In this case, I strongly recommend checking your received data against a schema with a validator like Zod, yup, etc. If you can't trust your types, there is no point in using TypeScript.

References