What are discriminated unions in TypeScript?
A discriminated union is a union of types that each share a literal "discriminant" field - TypeScript uses that field to narrow the type automatically.
TypeScript
Overview
Discriminated unions (also called tagged unions) are a powerful TypeScript pattern for modelling data that can be one of several shapes. Each variant has a `kind` (or `type`, `tag`) field with a unique literal value. TypeScript uses this discriminant to narrow the type in `if`/`switch` blocks.
Key Points
- •Every variant in the union must have a common field with a unique literal type.
- •TypeScript narrows inside `if (x.kind === "...")` or `switch (x.kind)` blocks.
- •Exhaustiveness: if a new variant is added, TypeScript will error on unhandled `switch` cases when a `never` guard is present.
- •Great for: async states (loading/success/error), action types in reducers, API responses.
- •Safer than optional fields: instead of `{ loading?: boolean; data?: T; error?: Error }`, use explicit variant types.
Examples
Async state - the most common use case
typescripttype AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function render(state: AsyncState<User>) {
switch (state.status) {
case 'idle': return <p>Click to load</p>;
case 'loading': return <Spinner />;
case 'success': return <UserCard user={state.data} />; // data is typed as User
case 'error': return <p>{state.error.message}</p>; // error is typed as Error
}
}Event/action types in a reducer
typescripttype Action =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET'; payload: number }
| { type: 'RESET' };
function reducer(state: number, action: Action): number {
switch (action.type) {
case 'INCREMENT': return state + 1;
case 'DECREMENT': return state - 1;
case 'SET': return action.payload; // payload only available here
case 'RESET': return 0;
}
}Replace optional fields with a union (safer)
typescript// Messy: many optional fields, unclear which combinations are valid
type UserBad = {
name: string;
type: 'admin' | 'regular';
adminLevel?: number; // only for admin?
memberSince?: Date; // only for regular?
};
// Clean: each variant is explicit and self-contained
type User =
| { type: 'admin'; name: string; adminLevel: number }
| { type: 'regular'; name: string; memberSince: Date };Pro Tip
Design your discriminated unions so that accessing a field that only exists on one variant is impossible without first narrowing. If TypeScript can access `state.data` without a check, the union is not properly discriminated. The switch-on-`status` pattern makes this impossible by design.