Skip to main content

Discriminated unions in TypeScript

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.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?