Discriminated Unions
Model mutually exclusive states with tagged union types and exhaustive narrowing.
You are an expert in Discriminated Unions for writing type-safe TypeScript.
## Key Points
- Always use a single, consistent discriminant field name across all members (e.g., `type`, `kind`, `status`).
- Include an `assertNever` default branch to catch unhandled variants at compile time when new members are added.
- Keep each variant interface focused; shared fields belong in a base interface that every variant extends.
- Using a discriminant with type `string` instead of a string literal — TypeScript cannot narrow on a broad `string` type.
- Forgetting to return or break in each case, which silently falls through and bypasses type narrowing.
## Quick Example
```typescript
interface Loading { status: "loading" }
interface Success<T> { status: "success"; data: T }
interface Failure { status: "failure"; error: Error }
type AsyncState<T> = Loading | Success<T> | Failure;
```skilldb get typescript-patterns-skills/Discriminated UnionsFull skill: 124 linesDiscriminated Unions — TypeScript Patterns
You are an expert in Discriminated Unions for writing type-safe TypeScript.
Overview
A discriminated union is a union of object types that share a common literal-typed field (the discriminant). TypeScript uses this field to narrow the type inside switch and if blocks. Use discriminated unions to model state machines, result types, message protocols, and any domain where an entity can be in exactly one of several distinct states.
Core Philosophy
Discriminated unions encode the principle that an entity's valid operations depend on its current state. Rather than modeling state as a bag of optional fields where half might be undefined at any given moment, you model each state as its own type with exactly the fields that are relevant. The compiler then enforces that you handle each state explicitly before accessing state-specific data.
This pattern aligns TypeScript's type system with how domain logic actually works. An HTTP response that is still loading has no body; a failed request has an error but no data. By making these states distinct types unified under a discriminant, you make illegal states unrepresentable. The compiler becomes your ally: it refuses to let you access .data until you have checked that the status is "success", and it warns you when a new variant is added that you have not handled.
Exhaustiveness checking is the crown jewel of discriminated unions. By adding a default: assertNever(x) branch, you ensure that every switch statement is updated whenever a new variant is introduced. This turns what would be a runtime bug — silently ignoring a new state — into a compile-time error that points you to every location that needs updating.
Core Concepts
Defining a discriminated union:
interface Loading { status: "loading" }
interface Success<T> { status: "success"; data: T }
interface Failure { status: "failure"; error: Error }
type AsyncState<T> = Loading | Success<T> | Failure;
Narrowing with switch:
function render<T>(state: AsyncState<T>): string {
switch (state.status) {
case "loading": return "Loading...";
case "success": return JSON.stringify(state.data);
case "failure": return state.error.message;
}
}
Exhaustiveness checking with never:
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}
function handle<T>(state: AsyncState<T>): void {
switch (state.status) {
case "loading": break;
case "success": break;
case "failure": break;
default: assertNever(state);
}
}
Implementation Patterns
Event system with discriminated payloads:
type AppEvent =
| { type: "USER_LOGIN"; userId: string }
| { type: "USER_LOGOUT" }
| { type: "ITEM_ADDED"; itemId: string; quantity: number };
function dispatch(event: AppEvent): void {
switch (event.type) {
case "USER_LOGIN":
console.log(`User ${event.userId} logged in`);
break;
case "ITEM_ADDED":
console.log(`Added ${event.quantity} of ${event.itemId}`);
break;
case "USER_LOGOUT":
console.log("Logged out");
break;
}
}
Result type for error handling without exceptions:
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
function divide(a: number, b: number): Result<number, string> {
if (b === 0) return { ok: false, error: "Division by zero" };
return { ok: true, value: a / b };
}
Best Practices
- Always use a single, consistent discriminant field name across all members (e.g.,
type,kind,status). - Include an
assertNeverdefault branch to catch unhandled variants at compile time when new members are added. - Keep each variant interface focused; shared fields belong in a base interface that every variant extends.
Common Pitfalls
- Using a discriminant with type
stringinstead of a string literal — TypeScript cannot narrow on a broadstringtype. - Forgetting to return or break in each case, which silently falls through and bypasses type narrowing.
Anti-Patterns
Using optional fields instead of discriminated variants. An interface with data?: T and error?: Error allows states where both are present or both are absent. This creates ambiguity that discriminated unions eliminate entirely by making each state's fields explicit and mutually exclusive.
Choosing a non-literal discriminant type. If the discriminant field is typed as string rather than a string literal like "loading" or "success", TypeScript cannot narrow the union in switch or if blocks. Always use literal types for the discriminant.
Skipping the assertNever default branch. Without exhaustiveness checking, adding a new variant to the union compiles silently even though existing switch statements do not handle it. The assertNever pattern is cheap insurance that turns this into a compile error.
Nesting discriminated unions without clear naming. A union whose variants themselves contain unions with the same discriminant field name creates confusion about which level is being narrowed. Use distinct discriminant names (type vs subtype, or kind vs variant) when nesting.
Putting shared logic outside the switch. If you access the discriminant before the switch and store derived values in local variables, those variables do not benefit from narrowing inside each case. Keep discriminant-dependent logic inside the case branches where the compiler has narrowed the type.
Install this skill directly: skilldb add typescript-patterns-skills
Related Skills
Branded Types
Create nominally distinct types from structural primitives using phantom brands.
Builder Pattern
Implement fluent, type-accumulating builders that enforce required fields at compile time.
Conditional Types
Branch at the type level with conditional expressions, infer, and distributive behavior.
Generic Constraints
Constrain generic type parameters to enforce structural and behavioral contracts at compile time.
Module Augmentation
Extend third-party and global type declarations without modifying source using module augmentation.
Template Literal Types
Construct and parse string-level types using template literal syntax for compile-time string validation.