⚡IntermediateTypeScript in Practice
Discriminated Unions (Advanced)
Model state machines, request/response cycles, and complex domain states using exhaustive discriminated unions and the satisfies operator.
Overview
You already know that discriminated unions use a shared literal property (the "discriminant") to allow TypeScript to narrow across union members. At the intermediate level, the power of this pattern becomes clear when applied to **state machines**, **async state tracking**, and **event systems**.
The key advanced techniques are:
**Exhaustive checking** - using `never` or the `satisfies` operator to guarantee every case is handled at compile time, preventing silent bugs when new states are added.
**Payload-typed events** - an event system where each event name is tied to a specific payload type, enabling type-safe dispatching and subscribing.
**State machine types** - encoding the valid states and transitions of UI state (`idle`, `loading`, `success`, `error`) so that TypeScript prevents you from accessing `data` before the request succeeds or `error` when the state is still `loading`.
Key Points
- •Discriminant property: a literal-typed field (`type`, `kind`, `status`) shared by all union members
- •TypeScript uses the discriminant to narrow to the specific member in if/switch blocks
- •Exhaustive switch: add `default: const _: never = x` to catch unhandled variants at compile time
- •`satisfies` operator (TS 4.9+): `x satisfies MyType` validates type without widening
- •State machine pattern: encode all valid states as discriminated union members
- •Async state: `type AsyncState<T> = Idle | Loading | Success<T> | Failure` models fetch lifecycle
- •Tagged event system: `type AppEvent = { type: "login"; user: User } | { type: "logout" }` - type-discriminated events
Examples
Async fetch state machine
interface User { id: number; name: string }
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function renderUser(state: FetchState<User>): string {
switch (state.status) {
case 'idle':
return 'Click to load user';
case 'loading':
return 'Loading...';
case 'success':
// TypeScript knows state.data is User here
return `Welcome, ${state.data.name}!`;
case 'error':
// TypeScript knows state.error is Error here
return `Error: ${state.error.message}`;
default:
// Exhaustive check - TypeScript errors if a case is missing
const _: never = state;
throw new Error('Unhandled state');
}
}
let state: FetchState<User> = { status: 'idle' };
console.log(renderUser(state)); // "Click to load user"
state = { status: 'loading' };
console.log(renderUser(state)); // "Loading..."
state = { status: 'success', data: { id: 1, name: 'Alice' } };
console.log(renderUser(state)); // "Welcome, Alice!"
state = { status: 'error', error: new Error('404 Not Found') };
console.log(renderUser(state)); // "Error: 404 Not Found"Type-safe event bus with discriminated union
// All events are a discriminated union on "type"
type AppEvent =
| { type: 'user:login'; user: { id: number; name: string } }
| { type: 'user:logout' }
| { type: 'cart:add'; productId: number; quantity: number }
| { type: 'cart:clear' }
| { type: 'order:placed'; orderId: number; total: number };
// Generic event dispatcher
type EventType = AppEvent['type'];
type EventPayload<T extends EventType> = Extract<AppEvent, { type: T }>;
class EventBus {
private handlers = new Map<string, Set<(e: AppEvent) => void>>();
on<T extends EventType>(
type: T,
handler: (event: EventPayload<T>) => void
): void {
if (!this.handlers.has(type)) this.handlers.set(type, new Set());
this.handlers.get(type)!.add(handler as (e: AppEvent) => void);
}
emit<T extends EventType>(event: EventPayload<T>): void {
this.handlers.get(event.type)?.forEach(h => h(event));
}
}
const bus = new EventBus();
// Handlers are fully typed - no casting
bus.on('user:login', ({ user }) => {
console.log('Logged in:', user.name); // user is { id: number; name: string }
});
bus.on('cart:add', ({ productId, quantity }) => {
console.log(`Added ${quantity}x product #${productId}`);
});
bus.emit({ type: 'user:login', user: { id: 1, name: 'Alice' } });
bus.emit({ type: 'cart:add', productId: 42, quantity: 2 });Using satisfies for exhaustive config validation
type Theme = 'light' | 'dark' | 'system';
type Language = 'en' | 'fr' | 'de' | 'ja';
interface ThemeConfig {
background: string;
foreground: string;
accent: string;
}
// satisfies validates each key against ThemeConfig without widening the type
const themeMap = {
light: { background: '#ffffff', foreground: '#000000', accent: '#3b82f6' },
dark: { background: '#0f172a', foreground: '#f8fafc', accent: '#60a5fa' },
system: { background: 'inherit', foreground: 'inherit', accent: '#3b82f6' },
} satisfies Record<Theme, ThemeConfig>;
// TypeScript still knows the exact string literals (not widened to string)
console.log(themeMap.dark.background); // "#0f172a" - precise type, not string
// Pattern: ensure all cases in a map are handled
type Action =
| { type: 'increment'; amount: number }
| { type: 'decrement'; amount: number }
| { type: 'reset' };
type Reducer<S, A> = (state: S, action: A) => S;
const counterReducer: Reducer<number, Action> = (state, action) => {
switch (action.type) {
case 'increment': return state + action.amount;
case 'decrement': return state - action.amount;
case 'reset': return 0;
default:
const _: never = action;
return state;
}
};
console.log(counterReducer(10, { type: 'increment', amount: 5 })); // 15
console.log(counterReducer(10, { type: 'reset' })); // 0Common Mistakes
- Using a non-literal discriminant type (e.g., `type: string`) - TypeScript cannot narrow a union by a string-typed property; discriminants must be literal types like `"success"` or `42`
- Forgetting the exhaustive default case - adding a new union member later will silently go unhandled unless you have the `never` check
- Making the discriminant optional (`type?: string`) - TypeScript requires the discriminant to be present on every member to narrow correctly
Note
Model your application's async states as `{ status: "idle" } | { status: "loading" } | { status: "success"; data: T } | { status: "error"; error: Error }`. This pattern prevents the "loading but also has data" impossible state that plain booleans (`isLoading`, `isError`, `data`) allow. If it can't exist, it can't be expressed.
Exercises
Write TypeScript code to solve each exercise. Click Run to compile and execute it right here, then mark as done.
Loading editor…
Completed: 0 / 3 exercises
Knowledge Check
Question 1 of 4