Exhaustiveness & never

The Course
•
5 min read

A switch over a discriminated union can account for every alternative. In switch (action.type), each case "add": or case "reset": selects one tag. The return exits the function, so no break is needed here. A default branch handles anything not matched by a case. After all variants are handled, the remaining value has type never: there is no possible member left. A helper accepting never can turn that fact into a checked exhaustiveness requirement.

function unreachable(value:never):never accepts no valid remaining variant and never returns normally because it throws. Call it in a default branch. If you later add another action without handling it, that action cannot be passed as never and the compiler points to the missing case.

The helper is not runtime validation of untrusted actions. A JavaScript caller or an assertion can still supply invalid data; validate external events at their boundary. Exhaustiveness checks maintain a known internal vocabulary during source changes.

Do not replace the default with a cast to never. That silences exactly the signal you wanted. Also avoid a broad string discriminant if the application really has a small closed set of actions.

Predict, run, and explain

Write the output and trace the important state changes before checking it. Then run the program. Use Check types first; execution is allowed only when the real compiler accepts the source.

Predict, then runTypeScript
type Action =
  | {
      type: "add";

      amount: number;
    }
  | { type: "reset" };

function unreachable(value: never): never {
  throw new Error("Unhandled action");
}

function reduce(count: number, action: Action): number {
  switch (action.type) {
    case "add":
      return count + action.amount;
    case "reset":
      return 0;
    default:
      return unreachable(action);
  }
}

console.log(reduce(2, { type: "add", amount: 3 }));
console.log(reduce(5, { type: "reset" }));

Enable JavaScript to edit and type-check this example.

Check your prediction
5
0

Read the compiler error

One real union member remains, so it is not never.

Find and fix the type errorTypeScript
type Value =
  | {
      kind: "text";

      text: string;
    }
  | {
      kind: "count";

      count: number;
    };

function unreachable(value: never): never {
  throw new Error("Unhandled");
}

function format(value: Value): string {
  if (value.kind === "text") {
    return value.text;
  }

  return unreachable(value);
}

Enable JavaScript to edit and type-check this example.

Check your diagnosis

Handle the count member before calling unreachable. The diagnostic is the intended evidence that a branch is missing.

Make a new variant require a deliberate branch

Never in an exhaustiveness helper means no known alternatives remain at that program point. Passing the unhandled value to the helper turns a forgotten branch into a compiler diagnostic. It is a maintenance check over your closed vocabulary, not validation of arbitrary external data.

A generic default message can hide a newly added case by continuing to compile. Keep a checked never branch when every alternative needs deliberate behavior. Avoid asserting the value as never: that removes the exact evidence you wanted the checker to demand.

Make a new variant require a deliberate branch: traceTypeScript
type Action = { type: "increment" } | { type: "reset" };

function unreachable(value: never): never {
  throw new Error("Unhandled action");
}

function update(count: number, action: Action): number {
  switch (action.type) {
    case "increment":
      return count + 1;
    case "reset":
      return 0;
    default:
      return unreachable(action);
  }
}

console.log(update(2, { type: "increment" }), update(2, { type: "reset" }));

Enable JavaScript to edit and type-check this example.

Check your prediction
3 0

The default is unreachable for valid Action values after both cases. Adding decrement to Action without implementing it should fail at that helper call.

Make a new variant require a deliberate branch: contrastTypeScript
type Choice =
  | {
      kind: "text";

      value: string;
    }
  | {
      kind: "count";

      value: number;
    };

function format(choice: Choice): string {
  if (choice.kind === "text") {
    return choice.value.trim();
  }

  return String(choice.value);
}

console.log(format({ kind: "text", value: " Types " }));
console.log(format({ kind: "count", value: 0 }));

Enable JavaScript to edit and type-check this example.

Check your prediction
Types
0

Control flow can eliminate alternatives through early return as well as switch. A checked never helper is especially valuable when later edits could leave an unhandled branch.

Try the rule on new inputs

Add a third variant without touching its formatter, inspect the actual error, then implement it. Explain why a catch-all string result and an assertion to never would both weaken the maintenance guarantee.

Your turn

Exercise

Exhaustiveness & never: practise

Write an exhaustive formatter for {kind:“text”;text:string} | {kind:“count”;count:number}. Return text or String(count), with an unreachable helper in the remaining branch. Print Types and 0.

Your workspaceTypeScript
// Keep the closed union and the checked never parameter.

Enable JavaScript to edit and type-check this example.

Hint 1: the idea

Never describes the absence of an unhandled variant, not missing runtime data.

Hint 2: a first step

Use switch(value.kind), then a default calling unreachable(value).

Show one solution
One possible solutionTypeScript
type Value =
  | {
      kind: "text";

      text: string;
    }
  | {
      kind: "count";

      count: number;
    };

function unreachable(value: never): never {
  throw new Error("Unhandled value");
}

function format(value: Value): string {
  switch (value.kind) {
    case "text":
      return value.text;
    case "count":
      return String(value.count);
    default:
      return unreachable(value);
  }
}

console.log(format({ kind: "text", text: "Types" }));
console.log(format({ kind: "count", count: 0 }));

Enable JavaScript to edit and type-check this example.

Types
0

Explain why the implementation meets the stated contract. Which part is enforced by the language or checker, and which part needs behavioral evidence?

Change it and test it

Add a boolean variant without updating the switch and inspect the diagnostic. Then implement it and restore a clean check. Explain why asserting the default value to never would defeat the maintenance guarantee.

Was this article helpful?

Your response is saved on this device.