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.
Master typescript discriminated unions
Take the TypeScript Essentials course with hands-on lessons and challenges.
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.
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.
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 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.
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.
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.
Using `string` instead of a literal type for the discriminant — e.g. `{ status: string }` instead of `{ status: "success" }`
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" }`.
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
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.
Skipping exhaustiveness checking — relying on the switch to 'probably' cover all cases without a `default: assertNever(x)` guard
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.
Using inconsistent discriminant property names across different unions — `type` in one, `kind` in another, `tag` in a third
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.
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.
Interactive lessons and challenges on Stanza, practice in VS Code, Cursor, or the web.
Interactive lessons and challenges, right in your code editor.
Check the free courses. No credit card.