Skip to content
Discriminated Unions in Practice
step 1/6

Reading — step 1 of 6

Learn

~2 min readAsync TypeScript

Discriminated unions (also called tagged unions or algebraic data types) are TypeScript's most powerful pattern for modeling state, API responses, and finite alternatives. The compiler narrows the type based on a discriminator field.

The shape

type State =
    | { status: 'idle' }
    | { status: 'loading' }
    | { status: 'success'; data: User[] }
    | { status: 'error'; message: string };

Every member has a status field with a literal type. That field is the discriminator.

Narrowing with switch

function render(s: State): string {
    switch (s.status) {
        case 'idle':    return 'click to load';
        case 'loading': return 'loading...';
        case 'success': return `${s.data.length} users`;   // s narrowed to success member
        case 'error':   return `failed: ${s.message}`;     // s narrowed to error
    }
}

Inside each case, TypeScript knows EXACTLY which variant you're in. Accessing s.data works in 'success', fails everywhere else.

Exhaustiveness checking with never

function render(s: State): string {
    switch (s.status) {
        case 'idle':    return 'click to load';
        case 'loading': return 'loading...';
        case 'success': return `${s.data.length} users`;
        case 'error':   return `failed: ${s.message}`;
        default:
            const _exhaustive: never = s;     // ← compile error if a variant is unhandled
            throw new Error(`unhandled: ${JSON.stringify(s)}`);
    }
}

The : never annotation only compiles if s has been narrowed to never — i.e., every case was handled. Add a new variant to State → this line lights up everywhere it's used. Free refactoring safety.

Modeling API responses

type Response<T> =
    | { ok: true; data: T }
    | { ok: false; status: number; error: string };

async function fetchUser(id: number): Promise<Response<User>> {
    const r = await fetch(`/api/users/${id}`);
    if (!r.ok) {
        return { ok: false, status: r.status, error: r.statusText };
    }
    return { ok: true, data: await r.json() };
}

const result = await fetchUser(42);
if (result.ok) {
    console.log(result.data.name);   // result narrowed to success
} else {
    console.error(`${result.status}: ${result.error}`);   // narrowed to failure
}

State machines

Finite state machines map perfectly:

type Order =
    | { state: 'cart'; items: Item[] }
    | { state: 'checkout'; items: Item[]; address: Address }
    | { state: 'paid'; items: Item[]; address: Address; transactionId: string }
    | { state: 'shipped'; items: Item[]; address: Address; transactionId: string; trackingNumber: string };

function transitionToShipped(o: Order, trackingNumber: string): Order {
    if (o.state !== 'paid') {
        throw new Error('can only ship paid orders');
    }
    return { ...o, state: 'shipped', trackingNumber };
}

The type guarantees an Order in 'shipped' state HAS a tracking number. No Order | undefined checks needed at use sites.

Common mistakes

  • Forgetting the discriminator field — without a literal-typed shared field, the compiler can't narrow.
  • Mixing strings and numbers as discriminators — use one or the other consistently.
  • Using a class hierarchy where a discriminated union would do — DUs compose better and don't lock you into class semantics.
  • Skipping the never exhaustiveness check — adding a variant later silently misses cases.

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…