Skip to content
Theory · Narrowing · lesson 9 of 12

Exhaustiveness with never

One day parcels of a new kind arrive on the sorting belt—long tubes. Envelopes and cans have their own chutes, but tubes have none. A tube rides past every chute and falls on the floor at the end of the belt, and nobody notices.

This happens in a program when a member is added to a union. There are three shapes now, but the switch in the label function still handles two:

TypeScript
type Shape =
    | { kind: "circle"; r: number }
    | { kind: "square"; side: number }
    | { kind: "triangle"; side: number };

function label(s: Shape) {
    switch (s.kind) {
        case "circle":
            return "circle, r = " + s.r;
        case "square":
            return "square, side = " + s.side;
    }
}

console.log(label({ kind: "triangle", side: 2 })); // undefined

tsc is silent. The triangle goes past both cases, the function reaches its end, and the program prints undefined. For a function with no result annotation this is legal: tsc infers the type string | undefined for it. A large project has more than one such switch, and finding by hand every place where a new member is forgotten is hard.

What is left in default

Recall the never type: it stands for a value that cannot exist. In a switch on the discriminant, each case removes its member from the type of s. If every member is handled, no shape can get into the default branch, and s has the type never there. This is what the exhaustiveness check is built on: the compiler makes sure the switch handles every member of the union:

TypeScript
type Shape =
    | { kind: "circle"; r: number }
    | { kind: "square"; side: number };

function label(s: Shape) {
    switch (s.kind) {
        case "circle":
            return "circle, r = " + s.r;
        case "square":
            return "square, side = " + s.side;
        default:
            const rest: never = s;
            return rest;
    }
}

Let's walk through the example line by line:

  1. case "circle" and case "square" handle both members of the union.
  2. In default, s has the type never: after the two cases, no members are left.
  3. const rest: never = s; compiles: a value of type never fits a variable of type never.
  4. return rest gives the function an answer on this path too. While every member is handled, the program never reaches this line.

The never line is a control chute at the end of the belt. As long as every kind of parcel has its own chute, nothing falls into the control one.

A switch over a union of literals itself works the same way. For example, if p has the type "card" | "cash", each case removes its literal from the type of p, and default is left with never.

A forgotten member

Now add a third member { kind: "triangle"; side: number } to Shape and change nothing in label. The triangle now gets into default, and a triangle does not fit the type never:

TEXT
error TS2322: Type '{ kind: "triangle"; side: number; }' is not assignable to type 'never'.

tsc points at the never line and names the member that was not handled. Add case "triangle", and the error goes away. This way the compiler finds every switch in the project that needs a new case.

Here the belt picture parts ways with TypeScript. At the post office, the alarm sounds only once a tube has already fallen into the control chute. tsc reports the forgotten member earlier—when it checks the code, before the program has ever run.

A function with a result annotation has a second hint too. If you write function label(s: Shape): string without a default, tsc notices the path with no return:

TEXT
error TS2366: Function lacks ending return statement and return type does not include 'undefined'.

But this message does not name the member, and a function that returns nothing gets no such error at all. The never check works in any function.

Interviewers like to ask how to make the compiler find an unhandled member of a union by itself. The answer: in default, assign the value to a variable of type never.

Common mistake

default returns a fallback answer:

TypeScript
type Shape =
    | { kind: "circle"; r: number }
    | { kind: "square"; side: number }
    | { kind: "triangle"; side: number };

function label(s: Shape) {
    switch (s.kind) {
        case "circle":
            return "circle, r = " + s.r;
        case "square":
            return "square, side = " + s.side;
        default:
            return "some shape"; // ✗
    }
}

tsc is silent: "some shape" is a legal string, and default may hold anything. The triangle gets the fallback answer, and nobody learns about the new member. Such a default catches every unhandled member, the forgotten ones included. Keep the never check in default, and write a case of its own for each member.

Key points

  • When a switch handles every member of a union, the value has the type never in default.
  • const rest: never = s; in default compiles only while every member is handled.
  • tsc shows a forgotten member on that line: Type '{ kind: "triangle"; side: number; }' is not assignable to type 'never'.
  • A fallback answer in default hides forgotten members from tsc.