Mark Sauer-Utley

Exhaustive Switch Statements in Typescript

typescript logo

The problem

When using sum types, you will probably find yourself writing a lot of code like this:

interface Dog {
  type: 'dog'
  age: number
}

interface Cat {
  type: 'cat'
  age: number
}

export type Animal = Cat | Dog

// some other file somewhere else
const getAgeInAnimalYears = (animal: Animal) => {
  switch (animal.type) {
    case 'dog':
      return animal.age * 7
    case 'cat':
      return animal.age === 1 ? 24 : 24 + animal.age * 5
  }
}

This is great for now, but what happens when you add Elephant​ as another animal type? Let's see:

interface Dog {
  type: 'dog'
  age: number
}interface Cat {
  type: 'cat'
  age: number
}interface Elephant {
  type: 'elephant'
  age: number
}export type Animal = Cat | Dog | Elephant
​

// Oh no! This is just going to return undefined!
const getAgeInAnimalYears = (animal: Animal) => {
  switch (animal.type) {
    case 'dog':
      return animal.age * 7
    case 'cat':
      return animal.age === 1 ? 24 : 24 + animal.age * 5
  }
}

Now our getAge​ function is going to return undefined for Elephant​s. We could have prevented this from exploding in our faces by adding a default​ to that switch​, but does that really solve the problem? That function would have returned something, but it also would have returned something wrong, which is worse. Instead, this code shouldn't compile until we fully-implement the new animal type of Elephant​. How can we do that?

Enter: never + absurd

Let's write a function called absurd​ that can never be called. We'll do this using the neverbottom type.

const absurd = (x: never) => {}

Since never​ is a type that can never be instantiated and the x​ argument is required here, this function is impossible to call. We can use this to ensure that our switch statement for getAgeInAnimalYears​ is exhaustive (meaning it has handled all of the possible cases for animal.type​) by throwing it into a default​ clause.

interface Dog {
  type: 'dog'
  age: number
}interface Cat {
  type: 'cat'
  age: number
}interface Elephant {
  type: 'elephant'
  age: number
}export type Animal = Cat | Dog | Elephant
​
​
// Now this won't compile!
const getAgeInAnimalYears = (animal: Animal) => {
  switch (animal.type) {
    case 'dog':
      return animal.age * 7
    case 'cat':
      return animal.age === 1 ? 24 : 24 + animal.age * 5
    default:
      absurd(animal)
      // => Argument of type 'Elephant' 
      // is not assignable to parameter of type 'never'.
      return 1
  }
}

Great, now the compiler is showing us exactly what code we need to refactor. Let's add that case statement to handle elephants.

interface Dog {
  type: 'dog'
  age: number
}interface Cat {
  type: 'cat'
  age: number
}interface Elephant {
  type: 'elephant'
  age: number
}export type Animal = Cat | Dog | Elephant
​
const getAgeInAnimalYears = (animal: Animal) => {
  switch (animal.type) {
    case 'dog':
      return animal.age * 7
    case 'cat':
      return animal.age === 1 ? 24 : 24 + animal.age * 5
    case 'elephant':
      return Math.round(animal.age * 1.14)
    default:
      absurd(animal) // animal is now of type `never`
      return 1
  }
}

Why do this?

The main benefit of this approach comes when it is time to refactor. If we use never​ to our advantage like this, refactoring code to support new types becomes trivial. We can also have a lot of confidence that, if our code compiles, it will likely work.

If we had not used absurd​ in this switch statement and instead had added a default​ that returns 1​ or something like that, adding Elephant​ would have likely resulted in bugs in our business logic, affecting customers and possibly causing them to leave our platform (who wants to use an application that can't calculate elephant's ages correctly?!?!?!).

Business-logic and compiler-driven refactoring

A way to think about this is that we are using the type system to model business logic. For example, an Application​ can be pending​, submitted​, or rejected​. It can never​ be anything else, and we want the compiler to enforce that. Later, we might want to add a new status, like re-submitted​. Imagine having to go through an entire prod codebase using text search to track down everywhere that type can surface? That sounds awful to me. Wait, I've done it before. it is awful. Instead, we should just be able to add that new re-submitted​ status and have the compiler show us everything we need update.

That's it!

Thanks for reading!

Want more of that sweet, sweet content? Here ya go.