Execute Program

Everyday TypeScript: Exhaustiveness Checking

Welcome to the Exhaustiveness Checking lesson!

This lesson is shown as static text below. However, it's designed to be used interactively. Click the button below to start!

  • We often write functions that take union types as arguments. For example, we might write a function that takes either a User or a Cat.

  • >
    interface User {
    kind: 'user'
    userName: string
    }

    interface Cat {
    kind: 'cat'
    catName: string
    }
  • The getName function below takes a User | Cat. It adds the userName or catName to a names array, passed as the second argument.

  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    function getName(hasName: User | Cat, names: string[]) {
    switch (hasName.kind) {
    case 'user':
    names.push(hasName.userName);
    break;
    case 'cat':
    names.push(hasName.catName);
    break;
    }
    }

    const names: string[] = [];
    getName({kind: 'user', userName: 'Amir'}, names);
    getName({kind: 'cat', catName: 'Ms. Fluff'}, names);

    names;
    Result:
    ['Amir', 'Ms. Fluff']Pass Icon
  • That function works well enough. (But you may object to names being passed as an argument. If so, just wait!)

  • Now suppose that we add more alternatives to the User | Cat union. We want to allow dogs as arguments as well.

  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    interface Dog {
    kind: 'dog'
    dogName: string
    }
  • Let's say we accidentally forget to handle the Dog case in the function body. When we pass a Dog, our function won't add its name to the array!

  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    function getName(hasName: User | Cat | Dog, names: string[]) {
    switch (hasName.kind) {
    case 'user':
    names.push(hasName.userName);
    break;
    case 'cat':
    names.push(hasName.catName);
    break;
    }
    }

    const names: string[] = [];
    getName({kind: 'user', userName: 'Amir'}, names);
    getName({kind: 'cat', catName: 'Ms. Fluff'}, names);
    getName({kind: 'dog', dogName: 'Woofy'}, names);

    names;
    Result:
    ['Amir', 'Ms. Fluff']Pass Icon
  • In this small example, the User | Cat | Dog type is defined right in the function's signature, close to the switch. That makes the mistake easy to spot.

  • Now imagine what this looks like in a large software system. We define the union type in one file, but use it in functions spread across dozens of other files. If we add a new type to the union, we might forget to update some of those functions. This kind of error might seem easy to spot and fix in our small example, but it poses a serious danger in large systems.

  • Fortunately, we can use the type system to prevent this type of bug. The getName function below is identical to our previous one, except that it now returns true in every case that handles a name. Suddenly, TypeScript gives us a type error, showing us the bug!

  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    function getName(hasName: User | Cat | Dog, names: string[]): true {
    switch (hasName.kind) {
    case 'user':
    names.push(hasName.userName);
    return true;
    case 'cat':
    names.push(hasName.catName);
    return true;
    }
    }
    Result:
    type error: Function lacks ending return statement and return type does not include 'undefined'.Pass Icon
  • Check the type error message. The compiler can see that our switch didn't handle all possible cases (user, cat, and dog). Since some cases are missing, the compiler knows that control will sometimes reach the end of the function. In JavaScript and TypeScript, that implicitly returns undefined. But we didn't put undefined in the function's return type, so we get a type error.

  • We can fix the type error by handling the dog case. Then the function type checks and everything works again.

  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    function getName(hasName: User | Cat | Dog, names: string[]): true {
    switch (hasName.kind) {
    case 'user':
    names.push(hasName.userName);
    return true;
    case 'cat':
    names.push(hasName.catName);
    return true;
    case 'dog':
    names.push(hasName.dogName);
    return true;
    }
    }

    const names: string[] = [];
    getName({kind: 'user', userName: 'Amir'}, names);
    getName({kind: 'cat', catName: 'Ms. Fluff'}, names);
    getName({kind: 'dog', dogName: 'Woofy'}, names);

    names;
    Result:
    ['Amir', 'Ms. Fluff', 'Woofy']Pass Icon
  • In these examples, we're using TypeScript to do "exhaustiveness checking". "Exhaustive" means "considering all possibilities". By writing our functions in this way, with each case returning a value, the TypeScript compiler checks that our code handles all possibilities.

  • Our examples above returned true, and had an explicit return type of true, which is what makes all of this work. The true value isn't useful to us at runtime, but we have to return something in order to use exhaustiveness checking.

  • Sometimes, a synthetic return value like true is fine, but most well-written functions won't need a synthetic return value at all. For example, if our getName function simply returns the name as a string, we get exhaustiveness checking for free.

  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    function getName(hasName: User | Cat | Dog): string {
    switch (hasName.kind) {
    case 'user':
    return hasName.userName;
    case 'cat':
    return hasName.catName;
    case 'dog':
    return hasName.dogName;
    }
    }

    [
    getName({kind: 'user', userName: 'Amir'}),
    getName({kind: 'cat', catName: 'Ms. Fluff'}),
    getName({kind: 'dog', dogName: 'Woofy'}),
    ];
    Result:
    ['Amir', 'Ms. Fluff', 'Woofy']Pass Icon
  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    function getName(hasName: User | Cat | Dog): string {
    switch (hasName.kind) {
    case 'user':
    return hasName.userName;
    case 'cat':
    return hasName.catName;
    }
    }

    [
    getName({kind: 'user', userName: 'Amir'}),
    getName({kind: 'cat', catName: 'Ms. Fluff'}),
    getName({kind: 'dog', dogName: 'Woofy'}),
    ];
    Result:
    type error: Function lacks ending return statement and return type does not include 'undefined'.Pass Icon
  • Generally, we associate exhaustiveness checking with switch. However, it works with if as well.

  • Note: this code example reuses elements (variables, etc.) defined in earlier examples.
    >
    function getName(hasName: User | Cat | Dog): string {
    if (hasName.kind === 'user') {
    return hasName.userName;
    } else if (hasName.kind === 'cat') {
    return hasName.catName;
    }
    }

    [
    getName({kind: 'user', userName: 'Amir'}),
    getName({kind: 'cat', catName: 'Ms. Fluff'}),
    getName({kind: 'dog', dogName: 'Woofy'}),
    ];
    Result:
    type error: Function lacks ending return statement and return type does not include 'undefined'.Pass Icon
  • Here's a code problem:

    The species function below uses a switch statement to return the species of various Animals. Originally, the Animal type was a union of Dog | Cat. Now, we've updated it to be a union of Dog | Cat | Horse.

    When we made that change, our species function below started type erroring. The problem is that it doesn't handle all of the possibilities: it's not "exhaustive". Add horse support to the function. Horses' species is "equus ferus caballus".

    Remember to add a new case. Don't use default:!

    type Dog = {kind: 'dog'};
    type Cat = {kind: 'cat'};
    type Horse = {kind: 'horse'};
    type Animal = Dog | Cat | Horse;
    function species(animal: Animal): string {
    switch (animal.kind) {
    case 'dog':
    return 'canis familiaris';
    case 'cat':
    return 'felis catus';
    case 'horse':
    return 'equus ferus caballus';
    }
    }
    [
    species({kind: 'dog'}),
    species({kind: 'cat'}),
    species({kind: 'horse'}),
    ];
    Goal:
    ['canis familiaris', 'felis catus', 'equus ferus caballus']
    Yours:
    ['canis familiaris', 'felis catus', 'equus ferus caballus']Pass Icon
  • We asked you not to use default in that code problem. Depending on how you learned JavaScript, this lesson may seem to violate a rule that you've heard: "always provide a default: inside every switch".

  • That rule exists because JavaScript has no type system, so it can't check for exhaustiveness. Adding a default in JavaScript ensures that we at least consider what happens when none of our cases match.

  • In TypeScript, providing a default: inside of our switch effectively disables exhaustiveness checking. The whole point of default: is to handle any case, no matter what it is. With a default:, there can be no unhandled cases, so TypeScript can't check for exhaustiveness.

  • The reality is that default: is good in some, but not all, situations. For example, in our species function, we probably don't want a default species name. That idea doesn't even make sense!

  • In some other cases, a default makes perfect sense. For example, imagine that we're writing a dueForHeartWormMedicine function. A few species of animals need periodic medicine to prevent heart worms, but most species don't. It makes sense to write a switch to handle the few species who need the medicine, then add a default: return false to handle the rest.

  • Some TypeScript features look esoteric but are actually important. This is one of those features! Type unions are useful, so complex TypeScript applications are full of them. But a union isn't useful unless you actually unpack it somewhere by running different code for the different cases, like User | Cat | Dog. Each time we unpack a union, there's a chance we'll forget to handle a case.

  • Fortunately, there's a way to prevent the mistake. When you use switch on a TypeScript union:

    1. Put the switch inside of a function where each case returns.
    2. Give the function an explicit return type.
    3. Don't add a default unless you're very sure that you need one.
  • Sometimes you'll have to refactor away from this simple design, but in most cases it will work, and it will ensure that you benefit from exhaustiveness checking.

  • Fortunately, extracting a function like this usually makes the code more readable, as it did for the examples in this lesson. Our original getName function awkwardly pushed the species name into an array. But our revised version with exhaustiveness checking simply returned the name, which is much easier to work with.