C.W.K.
Stream
Lesson 03 of 05 · published

Discriminated Unions: The Tagged Pattern

~12 min · unions-intersections, discriminated-unions, tagged-unions, state-machines

Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete
"The single most useful pattern in TypeScript. Once you see it, you find places to use it every day."

What a discriminated union is

A discriminated union is a union of object types where every variant carries a property with a literal type — the discriminator — whose value is unique to that variant. The compiler uses the discriminator to narrow the whole object when you check it.

The classic example: shapes.

type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'rect'; w: number; h: number };

Every variant has a kind field whose value is a literal string unique to that variant. When you check if (shape.kind === 'circle'), the compiler narrows shape to just the circle variant — and you can safely read shape.radius.

Why this pattern is so good

Discriminated unions model real domain concepts cleanly: states (idle/loading/done/error), events (click/keypress/scroll), API responses (success/error), shapes (circle/square/rect). Anywhere data has 'this OR that, with shape that depends on which,' a discriminated union is the right model.

The combined effect: the compiler keeps the variants distinct, narrowing happens automatically inside if/switch, and exhaustiveness can be enforced via never (next lesson). The pattern scales from 2 variants to 20+ without losing clarity.

Discriminator field conventions

The discriminator field is conventionally named kind, type, or tag. Pick one and use it consistently across your codebase. React's reducer pattern uses type. Functional-language ports often use tag. Domain-specific projects pick kind to avoid colliding with JavaScript's built-in type semantics. The choice is style — consistency matters more than the specific name.

If you're modeling 'this OR that with different shapes,' discriminated unions are almost certainly the right model. They give you the type-safety of an enum, the structure of a tagged record, and the ergonomics of regular JavaScript objects — all without runtime machinery.

Pippa's confession

Pretty much every state machine in cwkPippa is a discriminated union. The conversation lifecycle, the council round state, the streaming message phases, the brain selection — all tagged with kind or status. The pattern is so much a default that I notice when something isn't one: 'why is this a plain object with optional fields instead of a discriminated union?' Usually it should have been.

Code

The shape pattern·typescript
// Shapes — the canonical example.
type Shape =
  | { kind: 'circle'; radius: number }
  | { kind: 'square'; side: number }
  | { kind: 'rect'; w: number; h: number };

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.radius ** 2;   // s narrowed to circle variant
    case 'square': return s.side ** 2;
    case 'rect':   return s.w * s.h;
  }
}

// Each branch knows exactly which variant it's in.
// The compiler tracks: inside 'circle' branch, s.radius is accessible; s.side is not.
State machines and Redux actions — same pattern·typescript
// State machines.
type QueryState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

function render<T>(state: QueryState<T>) {
  switch (state.status) {
    case 'idle':    return 'waiting';
    case 'loading': return 'spinner';
    case 'success': return `data: ${JSON.stringify(state.data)}`;     // state.data only here
    case 'error':   return `error: ${state.error.message}`;            // state.error only here
  }
}

// Redux-style action types.
type Action =
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'SET'; payload: number };

function reduce(state: number, action: Action): number {
  switch (action.type) {
    case 'INCREMENT': return state + 1;
    case 'DECREMENT': return state - 1;
    case 'SET':       return action.payload;     // payload only here
  }
}

External links

Exercise

Model an Express-style HTTP handler result with a discriminated union: { status: 'json'; body: unknown } | { status: 'redirect'; to: string } | { status: 'error'; code: number }. Then write a respond(res, result) function that switches on status and handles each case. Confirm the compiler narrows the variant inside each branch.
Hint
The switch on status should give you autocompletion for body, to, or code only in the matching branch. If you try to access result.body in the redirect branch, the compiler complains — that's the narrowing working.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.