Exhaustive Checks in TypeScript

Exhaustiveness checking makes the compiler catch unhandled union variants. Learn the never-based pattern, the assertNever helper, and how to guarantee every case is covered.

6 min read

Exhaustiveness checking guarantees that every variant of a discriminated union is handled. When you add the never type to the default branch of a switch statement, TypeScript produces a compile error if any variant slips through unhandled.

This turns a bug that would otherwise only show up at runtime into a compile-time error you catch before shipping.

typescripttypescript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; sideLength: number };

Add a default branch that assigns the remaining value to a variable typed never, which only compiles if every other case has already been handled:

typescripttypescript
function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      const _check: never = shape;
      return _check;
  }
}

The const _check: never = shape line is the exhaustiveness check. Since both variants are handled, shape has type never in the default branch, and the assignment compiles. If a variant is missing, it does not.

Why Exhaustiveness Checking Exists

Discriminated unions grow. A network state goes from loading | success | error to include cached, or an event type adds a new timeout case.

Without exhaustiveness checking, the new variant silently falls through to the default branch, and nothing warns you that it is unhandled.

typescripttypescript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; sideLength: number }
  | { kind: "triangle"; base: number; height: number };

The existing getArea function from above was never updated for the new triangle variant, so it still only handles two cases:

typescripttypescript
function getArea(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "square":
      return shape.sideLength ** 2;
    default:
      const _check: never = shape;
      return _check;
  }
}

The exhaustiveness check catches the gap immediately, because a triangle shape reaches the default branch where only never is expected:

texttext
Type '{ kind: "triangle"; base: number; height: number; }' is not assignable to type 'never'.

The error names the unhandled variant directly. You add a triangle case, the error disappears, and your code handles every state.

The assertNever Helper

Repeating the same never assignment in every switch gets tedious. Extract the check into a reusable helper instead:

typescripttypescript
function assertNever(value: never): never {
  throw new Error(`Unhandled value: ${JSON.stringify(value)}`);
}

Call the helper from every default branch across the codebase, so every exhaustiveness check reports the same clear error shape:

typescripttypescript
function getShapeName(shape: Shape): string {
  switch (shape.kind) {
    case "circle": return "Circle";
    case "square": return "Square";
    case "triangle": return "Triangle";
    default: return assertNever(shape);
  }
}

The assertNever helper does two things: it satisfies the compiler at compile time, and it throws a clear error at runtime if the check ever fails. Its never return type also satisfies the enclosing function's own return type requirement.

Two Layers of Safety

Combine assertNever with an explicit function return type for double protection:

typescripttypescript
type Status =
  | { state: "idle" }
  | { state: "loading" }
  | { state: "done"; result: string };

Declaring the return type as string gives TypeScript a second signal to check against, on top of the never assignment inside the helper:

typescripttypescript
function render(status: Status): string {
  switch (status.state) {
    case "idle": return "Waiting...";
    case "loading": return "Loading...";
    case "done": return `Result: ${status.result}`;
    default: return assertNever(status);
  }
}

If a case is missing, the never assignment fails. If the switch has no return in some branch, the explicit string return type catches it. Both errors are compile-time.

Exhaustiveness checking flow

The cycle is: write the switch, let the compiler find gaps, add the missing case, repeat until clean.

Exhaustiveness Without a Discriminated Union

You can use exhaustiveness checking on any union, not just discriminated ones:

typescripttypescript
function describe(value: string | number | boolean): string {
  if (typeof value === "string") return `String: ${value}`;
  if (typeof value === "number") return `Number: ${value}`;
  if (typeof value === "boolean") return `Boolean: ${value}`;
  
  const _check: never = value; // Compiles — all types handled
  return _check;
}

TypeScript tracks which types remain through control flow. When none remain, the value is never and the assignment compiles.

When Exhaustiveness Checking Fails

Missing the return type annotation. Without an explicit return type on the function, TypeScript does not enforce that every path returns. A missing case silently returns undefined.

typescripttypescript
// No return type — missing case silently returns undefined
function render(status: Status) {
  switch (status.state) {
    case "idle": return "Waiting...";
    case "loading": return "Loading...";
    // Missing "done", no error without exhaustiveness check
  }
}

Always add a return type and the assertNever helper together.

Using a non-literal discriminant. If the discriminant is typed as a plain string instead of a literal like "idle", narrowing does not work and exhaustiveness checking cannot help.

Forgetting the default branch. Without a default branch, TypeScript cannot perform the never check. Always include it, even if you think you have handled everything.

Exhaustiveness vs if/else Chains

A switch statement with assertNever is the standard pattern, but the same never check also works at the end of an if/else chain:

ApproachReadabilityExhaustiveness check
switch with defaultClear for many variantsnever assignment in default
if/else chainClear for two or three variantsnever assignment after the last if
typescripttypescript
function handle(shape: Shape): number {
  if (shape.kind === "circle") return Math.PI * shape.radius ** 2;
  if (shape.kind === "square") return shape.sideLength ** 2;
  if (shape.kind === "triangle") return (shape.base * shape.height) / 2;
 
  const _check: never = shape;
  return _check;
}

The switch version scales better as variants are added, but both patterns get the same compile-time guarantee from the never assignment.

Common Mistakes

Only using assertNever without a return type. The function can still return undefined if you forget a return statement inside a case. Use both together.

Placing assertNever before handling all cases. The helper belongs in the default branch only. Putting it earlier causes false errors because TypeScript sees the union is not yet exhausted.

Not updating the union when adding a variant. The whole point of exhaustiveness checking is that adding a variant creates a compile error. Do not silence it with an as any cast. Add the missing case instead.

Forgetting that never is the bottom type. The never type is assignable to everything, but nothing except never is assignable to it. This is why the assignment check works.

Exhaustiveness checking works best with discriminated unions. For modeling states that benefit from this pattern, see model loading, success, and error states with unions.

Rune AI

Rune AI

Key Insights

  • Exhaustiveness checking guarantees every variant of a discriminated union is handled.
  • Use the never type: assign the remaining value to never in the default case.
  • The assertNever helper produces a clear error with the unhandled variant name.
  • When you add a new variant, the compiler tells you exactly which case is missing.
  • Combine with a return type annotation on the function for an extra layer of safety.
RunePowered by Rune AI

Frequently Asked Questions

What is exhaustiveness checking in TypeScript?

Exhaustiveness checking ensures every variant of a discriminated union is handled. You add a default case that assigns the value to a variable of type never. If any variant is unhandled, TypeScript produces a compile error because the remaining type is not assignable to never.

How does the assertNever function work?

assertNever takes a parameter of type never, a type to which no real value can be assigned. If a union variant is not handled by preceding cases, that variant's type flows into the default branch and fails to assign to never, producing a compile error.

Do I always need exhaustiveness checking?

Not always, but it is strongly recommended for discriminated unions that may grow over time. Without it, new variants silently fall through unhandled. For small, stable unions it is less critical.

Conclusion

Exhaustiveness checking turns a runtime oversight into a compile-time error. Add a default case with assertNever to every switch on a discriminated union, and TypeScript will tell you whenever a variant is missing. It is a small investment that pays off every time the union grows.