TypeScript Discriminated Unions
How to model variant types in TypeScript with discriminated unions: design, narrowing, exhaustiveness checks, and real-world patterns.
What you'll learn
- ✓What a discriminated union is
- ✓How to design a tag field
- ✓How narrowing works in switch and if
- ✓Exhaustiveness with never
- ✓Real patterns: requests, events, state
Prerequisites
- •Comfortable with basic TypeScript
A discriminated union is the cleanest way TypeScript lets you say “this value is one of several shapes, and you can tell which one by looking at a specific field.” It is the workhorse for modeling API responses, UI states, event types, and anywhere you previously reached for optional fields and if checks. Done well, it gives you compile-time exhaustiveness for free.
What and why
A discriminated union (also called a tagged union or sum type) is a union of object types that all share a literal-typed field, called the discriminant. TypeScript uses that field to narrow the union inside switch and if statements. You get autocomplete and exhaustive checks without runtime overhead.
Why bother instead of one object with optional fields? Because optional fields lie. A User with optional errorMessage does not tell you whether you have a user or an error. A union of { status: 'ok'; user } | { status: 'error'; message } does.
Mental model
The discriminant is a label on the box. The compiler reads the label and lets you open only the matching box.
Response = OkResp | ErrResp
|
v
switch (resp.kind) {
case 'ok': // narrowed to OkResp
case 'error': // narrowed to ErrResp
}
|
v
assertNever(resp) // compile error if a case is missing Hands-on example
A fetch result modeled as a union:
type FetchState<T> =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'success'; data: T }
| { kind: 'error'; message: string };
function render<T>(state: FetchState<T>): string {
switch (state.kind) {
case 'idle': return 'Click to start';
case 'loading': return 'Loading...';
case 'success': return `Got ${JSON.stringify(state.data)}`;
case 'error': return `Error: ${state.message}`;
}
}
Inside each case, TypeScript narrows state to the matching member. state.data only exists in 'success', state.message only in 'error'. No casts, no optional chaining.
A UI event modeled the same way:
type Action =
| { type: 'add'; item: string }
| { type: 'remove'; id: string }
| { type: 'clear' };
function reduce(state: string[], action: Action): string[] {
switch (action.type) {
case 'add': return [...state, action.item];
case 'remove': return state.filter(x => x !== action.id);
case 'clear': return [];
}
}
Exhaustiveness with never
The real power shows when you add a default case that asserts the variable is never. Now adding a new variant turns the compiler into a checklist.
function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}
function reduce(state: string[], action: Action): string[] {
switch (action.type) {
case 'add': return [...state, action.item];
case 'remove': return state.filter(x => x !== action.id);
case 'clear': return [];
default: return assertNever(action);
}
}
If a coworker adds { type: 'undo' } to Action, the default case fails to compile because action is no longer never there. The compiler points you to every reduce-like function that needs updating.
Designing the discriminant
The discriminant must be a literal type — a specific string, number, or boolean. Common conventions are kind, type, tag, or status. Stay consistent across the codebase.
Avoid overlapping shapes. If two variants both have an optional data field, narrowing on kind still works, but reading .data from kind: 'error' reflects a confused model. Each variant should carry exactly the fields it needs.
// Good: only success carries data
type R = { kind: 'success'; data: User } | { kind: 'error'; message: string };
// Bad: both carry data, defeating the discrimination
type R2 = { kind: 'success'; data?: User } | { kind: 'error'; data?: string };
Common pitfalls
- Using a non-literal type as the discriminant.
kind: stringwill not narrow. Use'success', notstring. - Forgetting
default: assertNever(...). Without it, new variants do not flag missing handlers. - Mixing discriminated unions with class hierarchies. Pick one approach; both at once is hard to maintain.
- Narrowing inside complex conditions. Sometimes TypeScript loses the narrowing across function boundaries. Extract a helper and pass the narrowed value in.
Best practices
- Use a single literal-typed field as the discriminant. Do not encode it across two fields.
- Pair every discriminated union switch with an exhaustiveness check.
- Name the discriminant consistently. Pick
kindortypeand use it everywhere. - Co-locate the union definition with the function that consumes it when possible. It keeps changes in one diff.
FAQ
Can the discriminant be a number? Yes, any literal type works: 0, 1, 2 or 'a', 'b'. Strings are most common because they read better.
Can a union member have no extra fields? Yes. { kind: 'idle' } is perfectly valid and very common for “no payload” states.
Does this work with classes? Yes. As long as each class has a literal-typed property, the same narrowing applies. But unions of plain objects are usually simpler.
How is this different from enums? Enums name a set of values. Discriminated unions name a set of shapes. They are complementary; you can even use an enum as the discriminant.
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 TypeScript Narrowing and Control Flow Analysis
Learn how TypeScript narrows types through control flow, using typeof, instanceof, in, and discriminated unions to write safer code.
- TypeScript TypeScript Branded Types Tutorial
Use branded types in TypeScript to add nominal safety on top of structural types. Stop mixing up UserIds, emails, and raw strings at compile time.
- TypeScript TypeScript Conditional Types Tutorial
Learn conditional types in TypeScript: distribution, infer, and how to build expressive utility types that adapt to the input shape.