Discriminated Unions & Exhaustive Checks
📖 Concept
Discriminated unions (tagged unions) are a pattern where each member of a union has a common property (the discriminant/tag) with a unique literal type. TypeScript uses this tag to automatically narrow the type.
This is arguably TypeScript's most important pattern for production code — it models state machines, API responses, events, and domain logic with complete type safety.
Exhaustiveness checking ensures you handle ALL cases. If you add a new variant to the union, TypeScript will error everywhere a case is missing. This prevents bugs when expanding state machines.
The never trick: In the default case, assign the narrowed value to a never variable. If all cases aren't handled, the value ISN'T never, causing a compile error.
Real-world applications:
- Redux/useReducer action types
- API response states (loading/success/error)
- Form validation results
- AST node types
- State machines (idle → loading → success/error)
🏠 Real-world analogy: A discriminated union is like a labeled package system. Every package has a "type" sticker — "fragile", "heavy", or "perishable". Workers check the sticker and handle each type differently. If a new type "hazardous" is added, the system flags any worker who doesn't handle it.
💻 Code Example
1// Classic discriminated union — state management2type RequestState<T> =3 | { status: "idle" }4 | { status: "loading" }5 | { status: "success"; data: T }6 | { status: "error"; error: Error; retryCount: number };78function renderUI<T>(state: RequestState<T>): string {9 switch (state.status) {10 case "idle":11 return "Press Start";12 case "loading":13 return "Loading...";14 case "success":15 return `Got data: ${JSON.stringify(state.data)}`;16 case "error":17 return `Error: ${state.error.message} (retry ${state.retryCount})`;18 // If you miss a case, TypeScript warns!19 }20}2122// ⭐ Exhaustiveness check with never23type Shape =24 | { kind: "circle"; radius: number }25 | { kind: "square"; side: number }26 | { kind: "triangle"; base: number; height: number };2728function area(shape: Shape): number {29 switch (shape.kind) {30 case "circle":31 return Math.PI * shape.radius ** 2;32 case "square":33 return shape.side ** 2;34 case "triangle":35 return 0.5 * shape.base * shape.height;36 default: {37 // If you add a new shape but forget the case, this errors!38 const _exhaustive: never = shape;39 throw new Error(`Unhandled shape: ${JSON.stringify(_exhaustive)}`);40 }41 }42}4344// Utility function for exhaustive checks45function assertNever(value: never, message?: string): never {46 throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);47}4849// Redux-style action types50type Action =51 | { type: "ADD_TODO"; payload: { text: string } }52 | { type: "TOGGLE_TODO"; payload: { id: number } }53 | { type: "REMOVE_TODO"; payload: { id: number } }54 | { type: "SET_FILTER"; payload: { filter: "all" | "active" | "done" } };5556interface Todo { id: number; text: string; done: boolean; }5758function todoReducer(state: Todo[], action: Action): Todo[] {59 switch (action.type) {60 case "ADD_TODO":61 return [...state, { id: Date.now(), text: action.payload.text, done: false }];62 case "TOGGLE_TODO":63 return state.map(t =>64 t.id === action.payload.id ? { ...t, done: !t.done } : t65 );66 case "REMOVE_TODO":67 return state.filter(t => t.id !== action.payload.id);68 case "SET_FILTER":69 return state; // filter applied in selector70 default:71 return assertNever(action); // Catches missing cases!72 }73}7475// Result type — functional error handling76type Result<T, E = Error> =77 | { ok: true; value: T }78 | { ok: false; error: E };7980function divide(a: number, b: number): Result<number, string> {81 if (b === 0) return { ok: false, error: "Division by zero" };82 return { ok: true, value: a / b };83}8485const result = divide(10, 3);86if (result.ok) {87 console.log(result.value.toFixed(2)); // TS knows value exists88} else {89 console.error(result.error.toUpperCase()); // TS knows error exists90}9192// Tree/AST node types93type Expr =94 | { type: "number"; value: number }95 | { type: "string"; value: string }96 | { type: "binary"; op: "+" | "-" | "*" | "/"; left: Expr; right: Expr }97 | { type: "unary"; op: "-" | "!"; operand: Expr };
🏋️ Practice Exercise
Mini Exercise:
- Create a discriminated union for payment methods (CreditCard, PayPal, BankTransfer) and write a
processPaymentfunction - Implement the
assertNeverutility and use it in a switch - Model an async state machine: Idle → Loading → Success/Error, with
retryfrom Error back to Loading - Create a
Result<T, E>type and write functions that chain Results - Add a new variant to an existing union and observe where TypeScript reports missing cases
⚠️ Common Mistakes
Forgetting the exhaustive check in the default case — without
assertNever, adding new variants won't cause compile errorsUsing
stringinstead of literal types for the discriminant —status: stringdoesn't narrow; must bestatus: "loading"(literal)Having too many variants in a single union — if your union has 20+ variants, consider grouping them into sub-unions
Not using discriminated unions for state management — plain boolean flags (
isLoading && !isError) create impossible statesPutting shared properties outside the discriminant check — TypeScript can only access properties common to ALL union members before narrowing
💼 Interview Questions
🎤 Mock Interview
Practice a live interview for Discriminated Unions & Exhaustive Checks