Suggest an editImprove this articleRefine the answer for “Discriminated unions in TypeScript”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**Discriminated union** - a union type where each member has a shared literal property (discriminant) that lets TypeScript auto-narrow to one specific member type. ```typescript type Result = | { status: 'success'; data: string } | { status: 'error'; error: Error }; function handle(r: Result) { if (r.status === 'success') console.log(r.data); // narrowed } ``` **Key:** the discriminant must be a required literal on every union member.Shown above the full answer for quick recall.Answer (EN)Image**Discriminated union** - a union type where each member has a shared literal property (the discriminant) that lets TypeScript narrow to a single member type during `switch` and `if` control flow checks. ## Theory ### TL;DR - Like shipping boxes labeled "FRAGILE", "CLOTHES", or "BOOKS": the label tells you how to handle each box without opening it - Main difference from plain unions: TypeScript auto-narrows based on the discriminant value, no manual type guards needed - Discriminant must be a **required literal type** on every member, same property name across all - Use when 2+ related shapes share structure but differ in specific fields; skip for unrelated primitives - Add `default: const _: never = x` in `switch` for compile-time exhaustiveness checking ### Quick example ```typescript type Event = | { type: 'click'; x: number; y: number } | { type: 'keydown'; key: string }; function handleEvent(event: Event) { if (event.type === 'click') { // TypeScript narrows here: x and y are safe to access console.log(`Clicked at ${event.x}, ${event.y}`); } else { // TypeScript narrows here: key is safe to access console.log(`Key pressed: ${event.key}`); } } ``` `type` is the discriminant. In each branch, TypeScript knows exactly which member you have. No casts, no `as Event`, no runtime errors from accessing the wrong property. ### Key difference from plain unions With a plain union like `string | number`, you need `typeof` checks on every access. With a discriminated union, TypeScript's control flow analysis reads the literal value of the discriminant property and collapses the type to one member automatically. The difference shows up most clearly when you access member-specific fields: with plain unions you get a type error until you check each possible type separately; with discriminated unions the check happens once at the discriminant and everything downstream is already narrowed. ### When to use - Multiple object shapes that share structure but have variant fields -> discriminated union - API responses that change shape depending on status -> `{ status: 'success'; data: T } | { status: 'error'; error: Error }` - Event handling where the event type determines which properties exist -> discriminated union - State machines where each state carries different data -> discriminated union - Unrelated primitives like `string | number` -> plain union, no discriminant needed - One type with a few optional fields -> single interface, not a union ### How the compiler handles this TypeScript's control flow analysis tracks discriminant values per branch. When it sees `shape.kind === 'circle'`, it assigns the `Circle` type to `shape` for everything inside that branch. No runtime cost: types are erased before V8 runs your code. Since TypeScript 4.4, `switch` statements on literal discriminants also flag unhandled cases when you place a `never` assignment in `default`. ### Common mistakes **Mistake 1: Using `string` instead of a literal type** ```typescript // Wrong: kind is string, no narrowing possible type Dog = { kind: string; barks: boolean }; // Right: kind is a literal type Dog = { kind: 'dog'; barks: boolean }; ``` TypeScript sees `kind: string` and cannot narrow. You end up writing `as Dog` everywhere and the type safety is gone. **Mistake 2: Different discriminant names across members** ```typescript // Wrong type Circle = { kind: 'circle'; radius: number }; type Square = { type: 'square'; size: number }; // 'type' instead of 'kind' type Shape = Circle | Square; shape.kind; // Property 'kind' does not exist on type 'Square' ``` All members must use the same property name. Pick one and stick with it. **Mistake 3: Optional discriminant** ```typescript // Wrong type Action = { type?: 'LOAD' }; // optional // Right type Action = { type: 'LOAD' }; // required ``` If the discriminant is optional, TypeScript cannot guarantee the value exists during narrowing. The whole mechanism breaks. **Mistake 4: Missing exhaustiveness check** ```typescript function area(shape: Shape) { switch (shape.kind) { case 'circle': return Math.PI * shape.radius ** 2; // Added 'triangle' later? Silent runtime fallthrough. } } // Fix: add this to default default: const _exhaustive: never = shape; // compile error if a case is missing return _exhaustive; ``` Without the `never` guard, a new union member added weeks later silently falls through to `undefined`. The `never` assignment turns that into a compile error immediately. **Mistake 5: Union literal inside a single member** ```typescript type A = { type: 'a' | 'b' }; // union inside one member ``` `if (x.type === 'a')` will not fully narrow to a distinct member because `A` was never split into separate members. Each member should carry exactly one literal. ### Real-world usage - **React Query**: `{ status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: Error }` - the shape `useQuery` returns internally - **Redux Toolkit**: `{ type: 'counter/increment' } | { type: 'counter/decrement' }` - every action is a discriminated union member - **Zod**: `{ success: true; data: T } | { success: false; error: ZodError }` - parse result uses `success` as the discriminant - **tRPC**: procedure results discriminated by an internal `_tag` field - **Express middleware**: typed request variants like `{ auth: 'user' } | { auth: 'admin' }` to enforce role-based access at the type level ### Follow-up questions **Q:** What is the difference between discriminated unions and polymorphic class hierarchies? **A:** Class hierarchies use nominal typing and `instanceof` checks. Discriminated unions work with plain objects and structural typing. Unions fit functional patterns better; classes fit OOP patterns better. **Q:** How do you enforce exhaustiveness in a `switch`? **A:** Add a `default` branch that assigns the value to `never`: `const _: never = action`. If any case is missing, TypeScript errors because the unhandled member type cannot be assigned to `never`. **Q:** Can the discriminant be a nested object property? **A:** Yes. `{ kind: { shape: 'circle' } }` works as a discriminant if the nested literal matches exactly. In practice, flat discriminants are much easier to work with and debug. **Q:** Does a discriminated union have any runtime overhead? **A:** None. TypeScript types are erased. The `switch` runs as plain JavaScript at the V8 level. **Q:** How do you extract a specific member type by discriminant value? **A:** Use the built-in `Extract` utility: `type ClickEvent = Extract<UIEvent, { type: 'click' }>`. This resolves to exactly `{ type: 'click'; x: number; y: number }`. **Q:** Works with generics? **A:** Yes, and it is one of the most common patterns: `type Result<T> = { ok: true; value: T } | { ok: false; error: string }`. The discriminant `ok` works the same regardless of what `T` is. ## Examples ### Basic: shape area calculator ```typescript type Circle = { kind: 'circle'; radius: number }; type Square = { kind: 'square'; size: number }; type Rectangle = { kind: 'rectangle'; width: number; height: number }; type Shape = Circle | Square | Rectangle; function getArea(shape: Shape): number { switch (shape.kind) { case 'circle': return Math.PI * shape.radius ** 2; // radius is safe here case 'square': return shape.size ** 2; // size is safe here case 'rectangle': return shape.width * shape.height; // width, height are safe here default: const _exhaustive: never = shape; // compile error if a case is missing return _exhaustive; } } console.log(getArea({ kind: 'circle', radius: 5 })); // 78.54 ``` Three shapes, one discriminant property `kind`. TypeScript narrows to a single member in each `case`. The `never` in `default` means adding a `Triangle` member without handling it causes a compile error right here. ### Intermediate: async state with the React Query pattern ```typescript type AsyncState<T> = | { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: Error }; function renderUserCard(state: AsyncState<{ name: string }>) { switch (state.status) { case 'idle': return 'Not started'; case 'loading': return 'Loading...'; case 'success': return `Hello, ${state.data.name}`; // data only exists in 'success' case 'error': return `Failed: ${state.error.message}`; // error only exists in 'error' } } ``` This pattern appears in almost every data-fetching hook. Without the discriminant, you would have `data?: T` and `error?: Error` on a single type, which allows reading `state.data` when the request failed. The discriminated union makes that a compile error. ### Advanced: nested unions with a generic Result type ```typescript type NetworkError = { kind: 'network'; statusCode: number }; type ValidationError = { kind: 'validation'; fields: Record<string, string> }; type AuthError = { kind: 'auth'; reason: 'expired' | 'invalid' }; type AppError = NetworkError | ValidationError | AuthError; type Result<T> = | { ok: true; value: T } | { ok: false; error: AppError }; function handleResult<T>(result: Result<T>): string { if (result.ok) { return `OK: ${JSON.stringify(result.value)}`; } // Narrowed to { ok: false; error: AppError } here // Now narrow again on the nested union switch (result.error.kind) { case 'network': return `Network error ${result.error.statusCode}`; case 'validation': return `Validation failed: ${Object.keys(result.error.fields).join(', ')}`; case 'auth': return `Auth failed: ${result.error.reason}`; default: const _: never = result.error; return _; } } ``` Two levels of narrowing: first on `ok`, then on `error.kind`. This is the `Result<T>` pattern from libraries like fp-ts. I have used this in production APIs where a single endpoint can fail in three distinct ways, and the nested union means every failure mode has its own typed fields that you cannot accidentally mix up.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.