Discriminated Unions & Exhaustive Checks

0/5 in this phase0/21 across the roadmap

📖 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

codeTap to expand ⛶
1// Classic discriminated union — state management
2type RequestState<T> =
3 | { status: "idle" }
4 | { status: "loading" }
5 | { status: "success"; data: T }
6 | { status: "error"; error: Error; retryCount: number };
7
8function 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}
21
22// ⭐ Exhaustiveness check with never
23type Shape =
24 | { kind: "circle"; radius: number }
25 | { kind: "square"; side: number }
26 | { kind: "triangle"; base: number; height: number };
27
28function 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}
43
44// Utility function for exhaustive checks
45function assertNever(value: never, message?: string): never {
46 throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
47}
48
49// Redux-style action types
50type 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" } };
55
56interface Todo { id: number; text: string; done: boolean; }
57
58function 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 } : t
65 );
66 case "REMOVE_TODO":
67 return state.filter(t => t.id !== action.payload.id);
68 case "SET_FILTER":
69 return state; // filter applied in selector
70 default:
71 return assertNever(action); // Catches missing cases!
72 }
73}
74
75// Result type — functional error handling
76type Result<T, E = Error> =
77 | { ok: true; value: T }
78 | { ok: false; error: E };
79
80function 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}
84
85const result = divide(10, 3);
86if (result.ok) {
87 console.log(result.value.toFixed(2)); // TS knows value exists
88} else {
89 console.error(result.error.toUpperCase()); // TS knows error exists
90}
91
92// Tree/AST node types
93type 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:

  1. Create a discriminated union for payment methods (CreditCard, PayPal, BankTransfer) and write a processPayment function
  2. Implement the assertNever utility and use it in a switch
  3. Model an async state machine: Idle → Loading → Success/Error, with retry from Error back to Loading
  4. Create a Result<T, E> type and write functions that chain Results
  5. 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 errors

  • Using string instead of literal types for the discriminant — status: string doesn't narrow; must be status: "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 states

  • Putting 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