A Case You Forgot

Programs grow. Suppose the game gets keys: you add type Key to the union Item, and now every switch on item.kind in the program needs a new case. In a big program, will you find them all?

You don't have to search: TypeScript finds them for you. You've already seen the first way, in a function with a return type where every case returns. Here, the key doesn't have its case yet:

type Potion = { kind: "potion"; name: string; heal: number };
type Weapon = { kind: "weapon"; name: string; damage: number };
type Key = { kind: "key"; name: string; opens: string };
type Item = Potion | Weapon | Key;

function describeItem(item: Item): string {
    switch (item.kind) {
        case "potion":
            return `${item.name} (heals ${item.heal})`;
        case "weapon":
            return `${item.name} (damage ${item.damage})`;
    }
}
main.ts(6,36): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

For a key, no case matches, and the function would reach its end without returning anything. That's what the message says. It helps, but it doesn't say which kind is missing. And in a function that returns nothing (void), there's no message at all: a forgotten key just quietly does nothing.

That's why TypeScript programmers add a check to the default branch. It works in every switch, and it names the forgotten kind:

        default:
            const unhandled: never = item;
            return unhandled;

With these three lines at the end of the switch above, TypeScript reports:

main.ts(13,19): error TS2322: Type 'Key' is not assignable to type 'never'.

How does it work? never is a special type: the type of a value that can't exist. No value fits into it; even const unhandled: never = 5; is an error (Type '5' is not assignable to type 'never'.). Each case narrows the item, so in default, only the kinds that have no case are left. When every kind has its case, nothing is left: the item has the type never there, and storing it in unhandled is fine (and default never runs). When you forget a kind, that kind is left over, and it doesn't fit into never. The message names the forgotten type: Key.

And why return unhandled;? Thanks to it, every branch of the function returns, so TypeScript doesn't add the TS2366 error from above as well. A never value fits any return type, and the line works in a void function too.

This check is called an exhaustiveness check: TypeScript checks that the switch handles every kind. Write it in every switch on a discriminated union. Then, when you add a new kind one day, you just run the program, and TypeScript lists every switch that needs a new case.

Typical mistakes:

  • A default that returns something else, such as default: return "unknown item";. For TypeScript, the forgotten kinds are then handled, and a key quietly becomes unknown item.
  • The variable without : never. const unhandled = item; is an ordinary variable that simply gets the type of whatever is left over, so the line itself checks nothing. It's the : never that says “nothing may be left here”.

Instructions

Add the exhaustiveness check, the default branch with never, to both switches: in describeItem and in useItem. The program still prints the same lines.

Now the game gets scrolls. Add type Scroll = { kind: "scroll"; name: string; spell: string }; to the union Item, and add { kind: "scroll", name: "Scroll of Light", spell: "Light" } to the end of the inventory. Run the program and add the cases that TypeScript asks for: describeItem returns Scroll of Light (spell: Light), and useItem prints Kira casts Light. The program then prints:

Healing Potion (heals 20)
Silver Sword (damage 12)
Old Key (opens the crypt)
Scroll of Light (spell: Light)
Kira drinks the Healing Potion
Kira draws the Silver Sword
Kira opens the crypt
Kira casts Light

Start programming for free

By signing up, you agree to the Terms of Service and Privacy Policy.

Or sign up with:

3/8