TypeScript

TypeScript Discriminated Unions👨‍💻

Discriminated unions are the single most underrated feature in TypeScript. They let you model states that are mutually exclusive — an API response is either a success with data or an error with a message, never both — and the compiler enforces that you handle every case. If you've ever written a chain of if statements checking for response.error and response.data in different combinations, discriminated unions eliminate that entire class of bugs.

Key Takeaways

  • 1A discriminated union is a union of object types that share a common literal property (the 'discriminant' or 'tag') — typically called `type`, `kind`, or `status`
  • 2When you switch or branch on the discriminant, TypeScript automatically narrows the type inside each branch, giving you access to variant-specific properties
  • 3Exhaustiveness checking with `never` in a default case makes the compiler reject code where a new variant is added but not handled — this catches bugs at build time, not in production
  • 4The discriminant must be a literal type (`"success"`, `"error"`) — if it's typed as plain `string`, narrowing won't work and you lose the entire benefit
  • 5Discriminated unions replace the fragile pattern of optional fields and boolean flags — instead of `{ data?: T; error?: string; loading: boolean }`, you get three distinct, self-documenting states
  • 6This pattern is foundational in Redux reducers, state machines, WebSocket message handlers, API clients, and any code that processes multiple event or response shapes

Master typescript discriminated unions

Take the TypeScript Essentials course with hands-on lessons and challenges.

Examples

API response — success or error, never both

typescript

This replaces the common anti-pattern of { data?: T; error?: string } where both fields are optional and you're never sure which combination you'll get. With a discriminated union, each branch has exactly the fields that make sense for that state.

Payment processing states

typescript

Each payment state carries exactly the data relevant to that state. The idle state has nothing — no null transactionId, no empty receipt. The failed state has retryable info that only makes sense for failures. The compiler ensures your UI handles all four states.

Exhaustiveness checking with never

typescript

The assertNever helper is a reusable pattern you should have in every project. It takes a never parameter, so if any variant reaches the default case, TypeScript flags it at compile time. Extract it into a shared utils file — you'll use it everywhere.

WebSocket message handler

typescript

WebSocket protocols naturally produce different message shapes. Without a discriminated union, you'd be doing unsafe property checks on untyped JSON. With it, each event handler gets typed access to exactly the fields that message carries.

Form field configuration — dynamic forms

typescript

Dynamic form builders are one of the best use cases for discriminated unions. Each field type has completely different configuration options. Without the discriminant, you'd either use a messy bag of optional properties or lose type safety entirely.

Discriminated unions with generics — typed Result pattern

typescript

The Result pattern (borrowed from Rust) combines discriminated unions with generics. The ok boolean is the discriminant. This is a clean alternative to try/catch for functions where errors are expected and should be handled explicitly rather than thrown.

Common Mistakes

Mistake:

Using `string` instead of a literal type for the discriminant — e.g. `{ status: string }` instead of `{ status: "success" }`

Fix:

The discriminant must be a string literal, number literal, or boolean literal type. If it's typed as `string`, TypeScript can't narrow the union in a switch statement because any string matches any string. Define each variant with a specific literal: `{ status: "success" }`, `{ status: "error" }`.

Mistake:

Modeling mutually exclusive states with optional properties — e.g. `{ data?: User; error?: string; loading: boolean }` — where invalid combinations like `{ data: user, error: 'oops', loading: true }` are technically possible

Fix:

Replace the optional-properties bag with a proper discriminated union: `{ status: "loading" } | { status: "success"; data: User } | { status: "error"; error: string }`. Each variant carries only the fields that make sense for that state. Invalid combinations become impossible to construct.

Mistake:

Skipping exhaustiveness checking — relying on the switch to 'probably' cover all cases without a `default: assertNever(x)` guard

Fix:

Always add a default case that assigns to `never`. Without it, adding a new variant to the union compiles silently, and the new case falls through to undefined behavior at runtime. The `never` assignment is a one-line safety net that costs nothing.

Mistake:

Using inconsistent discriminant property names across different unions — `type` in one, `kind` in another, `tag` in a third

Fix:

Pick one discriminant name per domain (or per codebase) and stick with it. Most teams use `type` for events/actions and `status` for state representations. Consistency makes the codebase predictable and reduces cognitive overhead when reading unfamiliar code.

Best Practices

  • Extract an `assertNever` utility function into your shared utils — you'll use it in every switch over a discriminated union, and it doubles as a runtime guard for unexpected values from external data
  • Prefer discriminated unions over class hierarchies for data modeling — they're lighter, work with plain objects and JSON serialization, and play better with React state and Redux
  • Keep each variant's type as a named type alias (e.g. `type SuccessResponse = { status: "success"; data: T }`) — this makes error messages readable and lets you use variants independently in function signatures
  • Use the `satisfies` operator (TS 4.9+) when constructing union values to catch typos in the discriminant at the construction site rather than at the consumption site
  • When a discriminated union grows past 5-6 variants, consider grouping related variants or splitting into sub-unions — a 20-case switch is a code smell regardless of type safety

Summary

Discriminated unions model mutually exclusive states by tagging each variant with a unique literal property. TypeScript narrows the type automatically when you switch on the tag, giving each branch access to exactly the right fields. Combined with exhaustiveness checking via the `never` type, they catch missing cases at compile time. Use them for API responses, UI states, event handlers, form configurations, and anywhere you currently have optional-property bags or boolean flags trying to represent multiple distinct states.

Practice TypeScript with hands-on challenges

Learn typescript discriminated unions hands-on in your IDE

Interactive lessons and challenges on Stanza, practice in VS Code, Cursor, or the web.

Related Concepts

Related Cheatsheets

Master TypeScript with Stanza

Interactive lessons and challenges, right in your code editor.

Check the free courses. No credit card.