Reading — step 1 of 6
Learn
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
neverexhaustiveness 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…