Discriminated Unions for State Machines in TypeScript
Model application states, async flows, and finite state machines with discriminated unions and exhaustive pattern matching.
What you'll learn
- ✓Structure discriminated unions with a shared tag property
- ✓Use exhaustive switch statements for safe state handling
- ✓Model async data, forms, and finite state machines
Prerequisites
- •Basic TypeScript union types
- •Familiarity with type narrowing
A discriminated union is a union of object types that share a common literal property, called the discriminant or tag. TypeScript uses this tag to narrow the type inside switch and if statements, giving you access to variant-specific properties without unsafe casts. This pattern is the foundation for modeling states, events, and workflows in a type-safe way.
The Basic Pattern
Every variant in the union has a shared property with a unique literal type.
type Shape =
| { kind: 'circle'; radius: number }
| { kind: 'rect'; width: number; height: number }
| { kind: 'triangle'; base: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rect':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
}
}
Inside each case, TypeScript narrows shape to the specific variant. You can access radius only in the 'circle' branch, and width only in the 'rect' branch.
Exhaustive Checking
The real power comes from ensuring every variant is handled. If you add a new shape variant later, unhandled branches produce compile errors.
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'rect':
return shape.width * shape.height;
case 'triangle':
return (shape.base * shape.height) / 2;
default:
return assertNever(shape);
}
}
If someone adds { kind: 'polygon'; sides: number[]; } to the Shape union, the default branch no longer compiles because shape is not never. This forces the developer to handle the new case.
Modeling Async State
One of the most common uses is modeling the states of an async operation.
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function renderUsers(state: AsyncState<string[]>): string {
switch (state.status) {
case 'idle':
return 'Click to load users';
case 'loading':
return 'Loading...';
case 'success':
return state.data.join(', ');
case 'error':
return `Error: ${state.error.message}`;
}
}
This eliminates the error-prone pattern of checking multiple boolean flags like isLoading, isError, and hasData independently. A single discriminant property models exactly one state at a time.
Form State Machine
Forms have complex state transitions. Discriminated unions model them precisely.
type FormState =
| { step: 'editing'; values: Record<string, string>; errors: Record<string, string> }
| { step: 'validating'; values: Record<string, string> }
| { step: 'submitting'; values: Record<string, string> }
| { step: 'submitted'; confirmation: string }
| { step: 'failed'; values: Record<string, string>; error: string };
type FormAction =
| { type: 'UPDATE_FIELD'; field: string; value: string }
| { type: 'SUBMIT' }
| { type: 'VALIDATION_PASSED' }
| { type: 'VALIDATION_FAILED'; errors: Record<string, string> }
| { type: 'SUBMIT_SUCCESS'; confirmation: string }
| { type: 'SUBMIT_ERROR'; error: string };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case 'UPDATE_FIELD':
if (state.step !== 'editing') return state;
return {
step: 'editing',
values: { ...state.values, [action.field]: action.value },
errors: {},
};
case 'SUBMIT':
if (state.step !== 'editing') return state;
return { step: 'validating', values: state.values };
case 'VALIDATION_PASSED':
if (state.step !== 'validating') return state;
return { step: 'submitting', values: state.values };
case 'VALIDATION_FAILED':
if (state.step !== 'validating') return state;
return { step: 'editing', values: state.values, errors: action.errors };
case 'SUBMIT_SUCCESS':
if (state.step !== 'submitting') return state;
return { step: 'submitted', confirmation: action.confirmation };
case 'SUBMIT_ERROR':
if (state.step !== 'submitting') return state;
return { step: 'failed', values: state.values, error: action.error };
}
}
Each state only carries the data relevant to it. You cannot access confirmation unless the form is in the 'submitted' state.
Event Systems
Discriminated unions are natural for typed event systems.
type AppEvent =
| { type: 'USER_LOGIN'; userId: string; timestamp: number }
| { type: 'USER_LOGOUT'; userId: string; timestamp: number }
| { type: 'PAGE_VIEW'; path: string; referrer: string | null }
| { type: 'PURCHASE'; productId: string; amount: number };
function trackEvent(event: AppEvent): void {
switch (event.type) {
case 'USER_LOGIN':
console.log(`User ${event.userId} logged in`);
break;
case 'USER_LOGOUT':
console.log(`User ${event.userId} logged out`);
break;
case 'PAGE_VIEW':
console.log(`Page view: ${event.path}`);
break;
case 'PURCHASE':
console.log(`Purchase: ${event.productId} for $${event.amount}`);
break;
}
}
Nested Discriminants
Complex domains sometimes need two levels of discrimination.
type Notification =
| { channel: 'email'; to: string; subject: string; body: string }
| { channel: 'sms'; to: string; message: string }
| { channel: 'push'; deviceId: string; title: string; body: string };
type DeliveryResult =
| { channel: 'email'; status: 'sent'; messageId: string }
| { channel: 'email'; status: 'bounced'; reason: string }
| { channel: 'sms'; status: 'delivered'; sid: string }
| { channel: 'sms'; status: 'failed'; errorCode: number }
| { channel: 'push'; status: 'delivered' }
| { channel: 'push'; status: 'unregistered'; deviceId: string };
function handleResult(result: DeliveryResult): void {
switch (result.channel) {
case 'email':
if (result.status === 'bounced') {
console.log(`Email bounced: ${result.reason}`);
}
break;
case 'sms':
if (result.status === 'failed') {
console.log(`SMS failed with code ${result.errorCode}`);
}
break;
case 'push':
if (result.status === 'unregistered') {
console.log(`Device ${result.deviceId} unregistered`);
}
break;
}
}
Utility: Extracting Variants
You can extract a specific variant from a discriminated union using the built-in Extract utility type.
type SuccessState = Extract<AsyncState<string[]>, { status: 'success' }>;
// { status: 'success'; data: string[] }
type ErrorState = Extract<AsyncState<string[]>, { status: 'error' }>;
// { status: 'error'; error: Error }
This is useful for function signatures that only accept certain variants.
function logError(state: Extract<AsyncState<unknown>, { status: 'error' }>): void {
console.error(state.error.message);
}
Discriminant Best Practices
Use string literals for discriminant values. Numeric discriminants work but are less readable. Keep the discriminant property name consistent across your codebase. Common choices are type, kind, status, and tag.
Name your discriminant values in a way that reflects the domain. 'loading' is better than 'state2'. Use SCREAMING_CASE for event types and lowercase for state names, following the convention of your team.
Keep variants flat when possible. Deeply nested discriminated unions are hard to work with. If you find yourself nesting, consider splitting into separate union types.
Wrap Up
Discriminated unions model mutually exclusive states with precision. The compiler narrows types inside switch branches, exhaustive checks prevent unhandled cases, and each variant carries only the data it needs. Use them for async state, form flows, event systems, and any domain where a value can be in exactly one of several states at a time.
Related articles
- TypeScript TypeScript Type Guards Deep Dive
Master TypeScript type guards: typeof, instanceof, discriminated unions, user-defined predicates, and assertion functions. Narrow types safely and idiomatically.
- TypeScript tsconfig.json Deep Dive: Every Option Explained
A comprehensive guide to tsconfig.json covering compiler options, module resolution, path aliases, project references, and recommended configurations.
- TypeScript TypeScript Generics: Advanced Patterns and Constraints
Master advanced TypeScript generics patterns including conditional constraints, recursive types, generic factories, and type-safe builder patterns with examples.
- TypeScript TypeScript Type Guards and Narrowing Explained
Learn how TypeScript type guards and control flow narrowing work, including typeof, instanceof, in, discriminated unions, and custom type predicates.