How to use discriminated unions.
Impossible states, unrepresentable.
A discriminated union is the single most important pattern in TypeScript. It's how you model any "one of these states" data — API responses, form states, Redux actions, UI modes. Every member shares a tag field with a unique literal, and checking that tag narrows to the exact member. This is what makes large app state manageable — impossible states become compile errors, not production bugs.
Short answer: Give every union member a shared literal field (kind, status, type) with a unique value. type Result = { status: 'loading' } | { status: 'success'; data: User } | { status: 'error'; message: string }. Then switch (result.status) narrows to the exact member — you get data only on success, message only on error. Add exhaustive checking with never and the compiler finds every case you forgot.
✅ Solution 1 — Model an API response
The most common real-world use case.
A fetch request has exactly three or four possible states: idle, loading, success, error. Each state has different fields. A discriminated union makes the relationship explicit — you can't accidentally read data while loading, and you can't have loading: true alongside a data field.
// ─── The discriminated union ───────────────── interface User { id: string; name: string } type ApiResult= | { status: 'idle' } | { status: 'loading'; startedAt: Date } | { status: 'success'; data: T } | { status: 'error'; message: string; retryable: boolean }; // ─── Render function with full narrowing ───── function render (r: ApiResult ): string { switch (r.status) { case 'idle': return 'Ready'; case 'loading': return Loading since ${r.startedAt.toISOString()}Data: ${JSON.stringify(r.data)}; // ✅ r.data case 'error': return Error: ${r.message}`; // ✅ r.message } } // ─── The compiler prevents impossible states ─ // Can't construct loading with data: // const bad: ApiResult = { // status: 'loading', // data: { id: '1', name: 'Alice' } // // ❌ 'data' not in loading member // }; // // Can't read data on loading: // function f(r: ApiResult // ─── Type guards for reuse ─────────────────── function isSuccess) { // if (r.status === 'loading') return r.data; // // ❌ Property 'data' does not exist on type '{ status: "loading"; ... }' // } (r: ApiResult ): r is { status: 'success'; data: T } { return r.status === 'success'; } const result: ApiResult = { status: 'success', data: { id: '1', name: 'Alice' } }; if (isSuccess(result)) { console.log(result.data.name); // ✅ narrowed to success member } console.log(render(result));
function render(r) { switch (r.status) { case 'idle': return 'Ready'; case 'loading': return Loading since${r.startedAt.toISOString()}Data: {r.message}`; } } function isSuccess(r) { return r.status === 'success'; } const result = { status: 'success', data: { id: '1', name: 'Alice' } }; if (isSuccess(result)) { console.log(result.data.name); } console.log(render(result));
Shared discriminant
status on every member, unique literal per state. That's the field the compiler uses to narrow.
Per-member fields
data only on success. message only on error. Accessing the wrong field is a compile error.
Switch narrows automatically
switch (r.status) — each case narrows r to the exact member. No casts, no assertions.
Generics compose
ApiResult — the success data type is a generic. One result type for every API endpoint.
If your state has more than two related fields, make it a discriminated union. The { loading: boolean; data?: T; error?: Error } shape permits { loading: true, data: user, error: err } — three impossible combinations that the compiler can't catch. A discriminated union makes those combinations unrepresentable. Two-way switches on a single literal are the cleanest way to model any UI state, form state, or request state in a real app. This is the pattern that separates robust codebases from buggy ones.
✅ Solution 2 — Exhaustive checking with never
Catch missing cases at compile time, forever.
When you add a new state to a discriminated union, TypeScript won't automatically warn you about all the places that need updating. Exhaustive checking solves this: force the compiler to verify every member is handled. Adding a new state triggers errors at every place that forgot it — like a compiler-enforced checklist.
// ─── The pattern ───────────────────────────── type Shape = | { kind: 'circle'; radius: number } | { kind: 'square'; side: number } | { kind: 'triangle'; base: number; height: number }; function area(s: Shape): number { switch (s.kind) { case 'circle': return Math.PI * s.radius ** 2; case 'square': return s.side ** 2; case 'triangle': return (s.base * s.height) / 2; default: // s is never here — all cases handled const _exhaustive: never = s; return _exhaustive; } } // ─── Add a new member → compile error ──────── type Shape2 = | { kind: 'circle'; radius: number } | { kind: 'square'; side: number } | { kind: 'triangle'; base: number; height: number } | { kind: 'rectangle'; width: number; height: number }; function area2(s: Shape2): number { switch (s.kind) { case 'circle': return Math.PI * s.radius ** 2; case 'square': return s.side ** 2; case 'triangle': return (s.base * s.height) / 2; // forgot rectangle! default: // ❌ Type 'Shape2' is not assignable to type 'never'. // Type '{ kind: "rectangle"; ... }' is not assignable const _exhaustive: never = s; return _exhaustive; } } // ─── The helper version ────────────────────── function assertNever(x: never): never { throw new Error(Unexpected value: ${JSON.stringify(x)}`); } function area3(s: Shape): number { switch (s.kind) { case 'circle': return Math.PI * s.radius ** 2; case 'square': return s.side ** 2; case 'triangle': return (s.base * s.height) / 2; default: return assertNever(s); // cleaner } } console.log(area({ kind: 'circle', radius: 2 })); console.log(area({ kind: 'square', side: 3 }));
function area(s) { switch (s.kind) { case 'circle': return Math.PI * s.radius ** 2; case 'square': return s.side ** 2; case 'triangle': return (s.base * s.height) / 2; default: const _exhaustive = s; return _exhaustive; } } function area2(s) { switch (s.kind) { case 'circle': return Math.PI * s.radius ** 2; case 'square': return s.side ** 2; case 'triangle': return (s.base * s.height) / 2; default: const _exhaustive = s; return _exhaustive; } } function assertNever(x) { throw new Error(Unexpected value:${JSON.stringify(x)}`); } function area3(s) { switch (s.kind) { case 'circle': return Math.PI * s.radius ** 2; case 'square': return s.side ** 2; case 'triangle': return (s.base * s.height) / 2; default: return assertNever(s); } } console.log(area({ kind: 'circle', radius: 2 })); console.log(area({ kind: 'square', side: 3 }));
never as proof
When all cases are handled, s is never. Assigning it proves no cases were missed — it's a compile-time proof of exhaustiveness.
New member = error
Add a case to the union, forget the switch, and the default fails. The compiler finds every place that needs updating.
assertNever helper
A function that takes never. If reached at runtime, throws. Cleaner than inline const _: never in every function.
Works with if-else too
Same pattern with if-else: after all the branches, s is never. Assign to a variable of type never at the end.
✅ Solution 3 — Reducer actions with discriminated unions
The pattern behind Redux, useReducer, and every state machine.
Every action in a reducer is one of a fixed set. Each action has different payload requirements. A discriminated union makes this explicit — type: 'add' requires a by, type: 'reset' requires nothing. Dispatching a wrong shape is a compile error, not a silent no-op.
// ─── State and actions ─────────────────────── interface State { count: number; history: number[]; } type Action = | { type: 'increment' } | { type: 'decrement' } | { type: 'add'; by: number } | { type: 'reset' } | { type: 'undo' }; // ─── The reducer ───────────────────────────── function reducer(state: State, action: Action): State { switch (action.type) { case 'increment': return { count: state.count + 1, history: [...state.history, state.count + 1] }; case 'decrement': return { count: state.count - 1, history: [...state.history, state.count - 1] }; case 'add': return { count: state.count + action.by, history: [...state.history, state.count + action.by] }; // ↑ action.by exists only here case 'reset': return { count: 0, history: [] }; case 'undo': const prev = state.history[state.history.length - 2] ?? 0; return { count: prev, history: state.history.slice(0, -1) }; default: const _exhaustive: never = action; return _exhaustive; } } // ─── Dispatch type-safety ──────────────────── function dispatch(action: Action): void { console.log('dispatch', action); } dispatch({ type: 'increment' }); // ✅ no payload dispatch({ type: 'add', by: 5 }); // ✅ by: number required dispatch({ type: 'reset' }); // ✅ // ─── Type errors caught at dispatch ────────── // dispatch({ type: 'add' }); // ❌ Property 'by' is missing // dispatch({ type: 'reset', by: 5 }); // ❌ Object literal may only specify known properties // dispatch({ type: 'multiply' }); // ❌ Type '"multiply"' is not assignable to type 'Action["type"]' // ─── Same shape for Redux ──────────────────── // Redux Toolkit uses exactly this pattern: // // const slice = createSlice({ // name: 'counter', // initialState: { count: 0 } as State, // reducers: { // increment: (s) => { s.count++ }, // add: (s, action: PayloadAction) => { s.count += action.payload } // } // }); // // Every action is a discriminated union member — // action.type narrows to the exact shape. // ─── useReducer in React ───────────────────── // The same pattern applies directly: // // const [state, dispatch] = useReducer(reducer, { count: 0, history: [] }); // dispatch({ type: 'add', by: 10 }); // // dispatch is fully typed from the Action union. // Try dispatching a wrong shape — compile error. const initial: State = { count: 0, history: [] }; const after1 = reducer(initial, { type: 'increment' }); const after2 = reducer(after1, { type: 'add', by: 10 }); console.log(after2);
function reducer(state, action) { switch (action.type) { case 'increment': return { count: state.count + 1, history: [...state.history, state.count + 1] }; case 'decrement': return { count: state.count - 1, history: [...state.history, state.count - 1] }; case 'add': return { count: state.count + action.by, history: [...state.history, state.count + action.by] }; case 'reset': return { count: 0, history: [] }; case 'undo': const prev = state.history[state.history.length - 2] ?? 0; return { count: prev, history: state.history.slice(0, -1) }; default: const _exhaustive = action; return _exhaustive; } } function dispatch(action) { console.log('dispatch', action); } dispatch({ type: 'increment' }); dispatch({ type: 'add', by: 5 }); dispatch({ type: 'reset' }); const initial = { count: 0, history: [] }; const after1 = reducer(initial, { type: 'increment' }); const after2 = reducer(after1, { type: 'add', by: 10 }); console.log(after2);
Actions as union
Each action variant is a member with its own required fields. 'add' requires by: number; 'reset' requires nothing.
Switch narrows the action
switch (action.type) — each case gets the specific action shape. action.by only exists in the 'add' case.
Dispatch is checked
dispatch({ type: 'add' }) fails — by is missing. dispatch({ type: 'multiply' }) fails — not a valid type.
Works with useReducer
Same pattern inside useReducer. dispatch is typed from the Action union. React's built-in reducer support takes this shape directly.
⚠️ Common mistakes
The discriminated union bugs that cost real time.
{ loading: boolean; data?: User; error?: Error } allows impossible combinations. Fix: discriminated union — each state is its own shape.
Two objects with no shared literal field can't be narrowed. Fix: add kind, status, or type to every member.
No never in the default — adding a new state doesn't error. Fix: always include const _: never = x in the default.
status: string isn't a discriminant — it's an index signature. Fix: status: 'loading' | 'success' | 'error' — must be literal types.
switch with a missing case — TypeScript may allow it if no never check. Fix: exhaustive pattern finds these.
(r as Success).data — bypasses the check. Fix: check r.status === 'success' first. The compiler then narrows without a cast.
const s = r.status; if (s === 'success') { r.data } — s being success doesn't narrow r. Fix: check r.status === 'success' directly.
Every component doing its own switch is repetition. Fix: extract isSuccess, isError guards — reusable, composable.
❓ Frequently asked
What is a discriminated union in TypeScript?
A discriminated union is a union where every member has a shared property (the discriminant) with a unique literal value. type Result = { status: 'loading' } | { status: 'success'; data: User } | { status: 'error'; message: string }. The status field is the discriminant. Checking result.status === 'success' narrows the type to that specific member.
Why use a discriminated union instead of optional fields?
Optional fields allow impossible combinations. { loading: boolean; data?: User; error?: Error } permits { loading: true, data: user, error: error } — a state that shouldn't exist. A discriminated union makes it unrepresentable: { status: 'loading' } has no data field, so you can't construct that bug.
What is exhaustive checking?
Exhaustive checking ensures you handle every member of a union. In a switch with a case per discriminant, the default branch gets type never. Assigning the value to a never variable proves all cases were handled — and adding a new union member without handling it causes a compile error.
What should I name the discriminant field?
Common conventions: kind (for shapes), status (for state machines), type (for Redux actions), tag (for tagged unions). The name doesn't matter to TypeScript — any field with a unique literal per member works. Pick a name that matches the domain.