Discriminated Unions & Exhaustiveness

Tag each variant with a literal field, then let the never type prove you handled them all.

A discriminated union gives every member a shared literal tag β€” kind, type, status, whatever reads well. Switching on that tag narrows the union to exactly one shape, and the compiler unlocks that shape's fields.

type Loading = { state: "loading" };
type Success = { state: "success"; data: string };
type Failure = { state: "error"; message: string };
type Result = Loading | Success | Failure;

function render(r: Result) {
  switch (r.state) {
    case "loading": return "...";
    case "success": return r.data;    // narrowed to Success
    case "error":   return r.message; // narrowed to Failure
  }
}

The exhaustiveness trick

Assign the value to a never in the default branch. If a teammate later adds a fourth variant and forgets to handle it, that assignment fails to compile β€” your switch becomes self-auditing. This is the single highest-return pattern in day-to-day TypeScript.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • A Redux reducer uses a discriminated action union with a never default to catch unhandled action types at compile time.
  • A notification system discriminates on a type field to render different components for email, push, and SMS notifications.
  • A compiler error formatter uses a discriminated union of error codes so adding a new code forces a handler to be written.

More examples

Exhaustive switch with never

The never-typed default branch causes a compile error if a new Shape variant is added without a matching case.

Example Β· ts
type Shape = { kind: 'circle'; radius: number }
           | { kind: 'rect'; w: number; h: number };

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.radius ** 2;
    case 'rect':   return s.w * s.h;
    default:
      const _exhaustive: never = s;
      return _exhaustive;
  }
}

Discriminated action union

Models a Redux-style reducer with a discriminated union so each action type narrows to its specific payload shape.

Example Β· ts
type Action =
  | { type: 'INCREMENT'; by: number }
  | { type: 'DECREMENT'; by: number }
  | { type: 'RESET' };

function reducer(count: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT': return count + action.by;
    case 'DECREMENT': return count - action.by;
    case 'RESET':     return 0;
  }
}

Exhaustiveness helper function

Extracts the never assertion into a reusable helper that also throws at runtime, covering both compile-time and runtime safety.

Example Β· ts
function assertNever(x: never): never {
  throw new Error('Unhandled case: ' + JSON.stringify(x));
}

type Fruit = 'apple' | 'banana' | 'cherry';

function describe(f: Fruit): string {
  switch (f) {
    case 'apple':  return 'red';
    case 'banana': return 'yellow';
    case 'cherry': return 'dark red';
    default: return assertNever(f);
  }
}

Discussion

  • Be the first to comment on this lesson.
Discriminated Unions & Exhaustiveness β€” TypeScript | SoundsCode