What Are Discriminated Unions in TypeScript?
Learn TypeScript discriminated unions, tag-based narrowing, and exhaustiveness checking with the never type — with examples.
Expected Interview Answer
A discriminated union is a union of object types that share a common literal-typed property, called the discriminant or tag, which TypeScript uses to automatically narrow the union to the exact matching variant inside a conditional or switch branch.
Instead of a loose union like `{ status: string; data?: X; error?: Y }` where every field is optional and nothing prevents invalid combinations, you model each state as its own object type with a shared literal tag field, such as `{ status: "success"; data: X } | { status: "error"; error: Y } | { status: "loading" }`. When you check `if (result.status === "success")`, TypeScript narrows `result` specifically to the success variant, so `result.data` is accessible and typed, while `result.error` is not even a valid property to reference in that branch — the compiler rules out the impossible combinations entirely rather than merely hoping the developer checks correctly. This pattern pairs especially well with a switch statement and the `never` type: assigning the unmatched-branch value to a variable typed `never` in the default case causes a compile error if a new variant is ever added without being handled, making discriminated unions a powerful tool for exhaustive state modeling.
- Makes invalid state combinations (e.g. success with an error field) unrepresentable
- Automatically narrows to the exact matching variant based on the tag check
- Enables exhaustiveness checking via the never type to catch unhandled new variants
- Produces self-documenting code where each state’s exact shape is explicit
AI Mentor Explanation
A discriminated union is like classifying a delivery outcome by a single result tag — "wicket", "boundary", or “dot-ball” — where each tag comes with only the fields relevant to it: a wicket has a dismissal type, a boundary has a run count, and neither has the other’s field. Checking the tag first means the scorer only ever reads fields that actually exist for that outcome, never accidentally asking for dismissal type on a boundary. This eliminates invalid combinations like “wicket with four runs scored off the same ball” from ever being represented. That tag-first, only-valid-fields-exist structure is exactly what a TypeScript discriminated union enforces.
Step-by-Step Explanation
Step 1
Model each state as its own object type
Give each variant a shared literal tag field, e.g. status: "success" | "error" | "loading".
Step 2
Include only the fields valid for that state
The success variant has data, the error variant has error — never both on one type.
Step 3
Check the tag to narrow
A comparison like result.status === "success" lets TypeScript narrow to that exact variant.
Step 4
Add exhaustiveness checking
In a switch default case, assign the value to a never-typed variable so unhandled new variants cause a compile error.
What Interviewer Expects
- Ability to model a real state (loading/success/error) as a discriminated union
- Understanding that the tag field must be a literal type, not a general string
- Knowledge of exhaustiveness checking using the never type in a switch default
- Explanation of why this beats a single object with many optional fields
Common Mistakes
- Using a single flat interface with optional fields instead of separate tagged variants
- Typing the discriminant field as `string` instead of a specific string literal union
- Forgetting exhaustiveness checking, so a new variant silently falls through unhandled
- Checking a non-discriminant field to narrow instead of the shared literal tag
Best Answer (HR Friendly)
“A discriminated union is a way to model something that can be in one of several distinct states — like loading, success, or error — where each state has its own exact set of fields. You check a shared “tag” field first, and TypeScript then knows precisely which fields are available, so you cannot accidentally read an error message from a successful result.”
Code Example
type FetchState<T> =
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string }
function render<T>(state: FetchState<T>): string {
switch (state.status) {
case 'loading':
return 'Loading...'
case 'success':
return `Data: ${JSON.stringify(state.data)}` // state.data is valid here
case 'error':
return `Error: ${state.error}` // state.error is valid here
default: {
const exhaustiveCheck: never = state
throw new Error(`Unhandled state: ${exhaustiveCheck}`)
}
}
}Follow-up Questions
- How does exhaustiveness checking with never catch a missing switch case at compile time?
- Why is the discriminant field required to be a literal type rather than string?
- How would you model a Redux-style action union using discriminated unions?
- What is the difference between a discriminated union and a class hierarchy for the same problem?
MCQ Practice
1. What is the discriminant in a discriminated union?
The discriminant is a shared literal-typed field (like status) that TypeScript uses to narrow the union.
2. What technique catches an unhandled new variant at compile time?
If a new variant is added without a matching case, TypeScript errors because it cannot assign it to never.
3. Why is a discriminated union preferred over one interface with many optional fields?
Separate tagged variants prevent illegal states like success-with-error, unlike a loose optional-fields object.
Flash Cards
What is a discriminant field? — A shared literal-typed property used to narrow a union to one specific variant.
Why use discriminated unions over optional fields? — They make invalid state combinations unrepresentable.
How do you get exhaustiveness checking? — Assign the unmatched value to a never-typed variable in the default case.
Example discriminant values for a fetch state? — "loading", "success", "error" as literal string tags.