iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

TS Discriminated Unions

Discriminated unions are the cleanest way to model "this is one of several variants". A common literal property — the discriminant — lets TypeScript narrow inside a switch with no extra checks.

The pattern

TS
type Shape =
    | { kind: 'circle'; r: number }
    | { kind: 'rect';   w: number; h: number }
    | { kind: 'triangle'; base: number; height: number };

function area(s: Shape): number {
    switch (s.kind) {
        case 'circle':   return Math.PI * s.r ** 2;
        case 'rect':     return s.w * s.h;
        case 'triangle': return 0.5 * s.base * s.height;
    }
}

Why this pattern wins

  • The discriminant kind is just a string at runtime — no class or instanceof.
  • TypeScript narrows automatically inside each case.
  • Adding a new variant is one line; the compiler shows every switch you forgot to update.
  • Serialises to JSON cleanly — perfect over the wire.

Network state — Loading / Error / Data

TS
type RequestState<T> =
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'error'; error: string }
    | { status: 'success'; data: T };

function render(state: RequestState<User>) {
    switch (state.status) {
        case 'idle':    return 'Press the button.';
        case 'loading': return 'Loading…';
        case 'error':   return state.error;     // narrowed — has .error
        case 'success': return state.data.name; // narrowed — has .data
    }
}

The bad alternative: { data?: User; loading: boolean; error?: string } — every consumer has to check three flags and remember which combinations are legal.

Exhaustiveness check

TS
function area(s: Shape): number {
    switch (s.kind) {
        case 'circle':   return Math.PI * s.r ** 2;
        case 'rect':     return s.w * s.h;
        case 'triangle': return 0.5 * s.base * s.height;
        default: {
            const _: never = s;      // errors if a new variant slips in
            throw new Error('unhandled');
        }
    }
}

Result type (Rust-style)

TS
type Result<T, E = string> =
    | { ok: true;  value: T }
    | { ok: false; error: E };

function parseId(s: string): Result<number> {
    const n = Number(s);
    return Number.isInteger(n)
        ? { ok: true,  value: n }
        : { ok: false, error: `not an int: ${s}` };
}
Tip: If you ever find yourself writing x?: T; y?: U; z?: V on a single object, ask if the same data could be a discriminated union. The signature becomes self-documenting and bugs vanish.

Example

Example
type Shape =
  | { kind: 'circle'; r: number }
  | { kind: 'rect';   w: number; h: number };

function area(s: Shape): number {
  switch (s.kind) {
    case 'circle': return Math.PI * s.r ** 2;
    case 'rect':   return s.w * s.h;
  }
}
console.log(area({ kind: 'circle', r: 5 }));
console.log(area({ kind: 'rect',   w: 4, h: 3 }));
Try it Yourself »

Exercise

Use a literal property as a tag — convention is the field…

type Shape = { : 'circle'; r: number } | { : 'rect'; w: number; h: number };

Test yourself

Q1. The "discriminant" is…
Q2. They make switch statements…
Q3. For "loading | error | data" state prefer…

Discussion

Loading…