Discriminated Unions

intermediate discriminated-union tagged-union exhaustiveness never

A discriminated union (also called a tagged union or algebraic data type) is a union of object types that all share one field — the discriminant — whose value is a literal type. That shared field tells TS exactly which variant we have.

In simple language: every shape carries a tag (kind, type, status, whatever) that uniquely identifies it. Check the tag, get the right type.

The shape of it

type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "square"; size: number }
  | { kind: "rectangle"; width: number; height: number };

Every variant has a kind field with a unique string literal. Now TS can tell them apart just by checking kind.

shape.kind
"circle"
→ radius: number
"square"
→ size: number
"rectangle"
→ width, height

Narrowing on the tag

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;  // shape is { kind: "circle"; radius: number }
    case "square":
      return shape.size ** 2;
    case "rectangle":
      return shape.width * shape.height;
  }
}

Inside each case, TS knows the exact variant. No casts, no as, no runtime type checks beyond the tag comparison.

Exhaustiveness check with never

The killer feature. Add a default case that assigns to never — if we ever extend the union and forget a case, TS errors right here.

function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle": return Math.PI * shape.radius ** 2;
    case "square": return shape.size ** 2;
    case "rectangle": return shape.width * shape.height;
    default:
      const _exhaustive: never = shape; // ❌ if Shape gains a variant
      throw new Error(`Unhandled shape: ${_exhaustive}`);
  }
}

Add { kind: "triangle"; ... } to Shape later — every switch that doesn’t handle it lights up red. Compile-time refactor safety.

Real-world pattern: API results

This is the single most common use case. A request can succeed OR fail; the success and failure shapes are different.

type ApiResult<T> =
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; message: string };

function render(result: ApiResult<User>) {
  if (result.status === "loading") return "Loading...";
  if (result.status === "error")   return `Error: ${result.message}`;
  return `Hello ${result.data.name}`; // status is "success"
}

Notice how we never access data accidentally on a loading result — the type system blocks it.

Real-world pattern: Redux/state machine actions

type Action =
  | { type: "increment" }
  | { type: "decrement" }
  | { type: "set"; value: number };

function reducer(state: number, action: Action): number {
  switch (action.type) {
    case "increment": return state + 1;
    case "decrement": return state - 1;
    case "set":       return action.value; // safely access value
  }
}

Picking the discriminant

  • Use a string literal field (kind, type, status). Not numeric — strings are clearer in logs.
  • One field per union. Don’t try to discriminate on two fields at once.
  • Same field name across all variants — that’s what makes the discrimination work.

Why not just instanceof?

Discriminated unions work with plain objects — no classes needed. They serialize cleanly to JSON, survive network boundaries, and are the standard pattern in functional languages (Rust enums, Haskell ADTs, OCaml variants).

Interview soundbite

“A discriminated union is a union of object types sharing one literal-typed field. The compiler narrows each branch based on that field — no casts, no runtime overhead. The never exhaustiveness pattern guarantees we handle every case, and any new variant added later breaks the build until we cover it.”