Kodokon kodokon.com

never, exhaustiveness, and advanced discriminated unions

You model your states with discriminated unions and lock in their exhaustiveness with the never type.

10 min · 3 questions

Open this lesson in Kodokon

never is the empty type: no value inhabits it. It appears when the compiler proves that a code path is impossible - after exhausting every member of a union, for example. That property makes it a design tool: if you can get the compiler to say never in the default branch of a switch, you have static proof that every case is handled. This is the foundation of exhaustiveness checking.

You discovered discriminated unions in the previous module; now it is time to turn them into a genuine design tool. Compared to a single interface stuffed with optional fields, a discriminated union makes impossible states unrepresentable: you cannot have both data and error, or a success without data. It is the go-to pattern for loading states, operation results, and event messages.

TYPESCRIPT
type FetchState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

function render(state: FetchState<string[]>): string {
  switch (state.status) {
    case "idle": return "Waiting";
    case "loading": return "Loading…";
    case "success": return state.data.join(", ");
    case "error": return state.error.message;
  }
}
data only exists in the success variant: no access anywhere else.

You have already come across the assertNever guard called in the default branch; let's now dissect exactly how it works. As long as every case is covered, its parameter has type never there and everything compiles. Add a variant to the union without updating the switch, and the forgotten variant is no longer assignable to never: compile-time error, with the name of the missing case in the message. You turn a production bug into a refused build.

TYPESCRIPT
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rect"; width: number; height: number };

function assertNever(value: never): never {
  throw new Error("Unhandled case: " + String(value));
}

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "square": return shape.side ** 2;
    case "rect": return shape.width * shape.height;
    default: return assertNever(shape);
  }
}
Adding a variant to Shape breaks the compilation of area.

Two advanced subtleties deserve your attention. First, discriminant narrowing propagates to nested structures only if you test the enclosing object directly; destructuring the discriminant on its own breaks the link (except for the cases handled since TypeScript 4.6 for joint destructuring). Second, never is absorbed in unions (string | never is just string): this is what allows conditional types to filter out variants, as Exclude does.

TYPESCRIPT
type Event =
  | { type: "click"; x: number; y: number }
  | { type: "keypress"; key: string };

type EventOf<T extends Event["type"]> =
  Extract<Event, { type: T }>;

type ClickEvent = EventOf<"click">;

function handle<T extends Event["type"]>(
  type: T,
  handler: (event: EventOf<T>) => void
): void {
  /* register the handler */
}

handle("click", (e) => console.log(e.x, e.y));
Extract filters the union by discriminant: the handler is precise.

Knowledge check

Make sure you remember the key points of this lesson.

  1. Why is a discriminated union preferable to an interface with optional fields for a loading state?
    • It uses less memory at runtime
    • It makes inconsistent combinations (data and error at the same time) unrepresentable
    • It avoids writing switch statements
    • It is mandatory in strict mode
  2. What happens if you add a variant to Shape without modifying the switch that contains assertNever?
    • An exception is thrown on every call to area
    • The code compiles but the new variant returns undefined
    • Compilation fails because the forgotten variant is not assignable to never
  3. What is the type string | never?
    • never
    • string
    • unknown
    • string | undefined