TypeScript Discriminated Unions Explained
A discriminated union tags each variant of a type with a shared literal field, letting TypeScript narrow the type automatically inside a switch or if check.
A discriminated union (also called a tagged union) is a TypeScript pattern where each variant of a union type shares a common field — the discriminant — set to a distinct literal value. Checking that field in an if or switch statement lets TypeScript automatically narrow the union down to the specific variant, with full type safety and no manual casting.
It’s one of the most useful patterns in the type system, because it solves a problem plain unions can’t: safely representing “one of several distinct shapes” data, like API responses, state machines, or event payloads.
The problem with plain unions
A basic union type says a value is one of several types, but says nothing about how to tell them apart at runtime:
type Result =
| { data: string }
| { error: string };
function handle(result: Result) {
if (result.data) { // Error: Property 'data' does not exist on type '{ error: string }'
console.log(result.data);
}
}
TypeScript can’t narrow this safely because both branches structurally overlap — there’s no field guaranteed to exist on one variant but not the other in a way the compiler can use to distinguish them.
Adding the discriminant
A discriminated union fixes this by giving every variant a literal field with a unique value:
type Result =
| { status: "success"; data: string }
| { status: "error"; error: string };
function handle(result: Result) {
if (result.status === "success") {
console.log(result.data); // narrowed to the success variant
} else {
console.log(result.error); // narrowed to the error variant
}
}
Once the compiler sees result.status === "success", it knows — inside that branch — that result can only be the variant where status is "success", so result.data is valid and result.error isn’t accessible. No type assertions, no as, no runtime type-checking library required.
Switch statements and exhaustiveness checks
Discriminated unions pair naturally with switch, especially for state machines with more than two states:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; error: string };
function render(state: RequestState): string {
switch (state.status) {
case "idle":
return "Waiting to start";
case "loading":
return "Loading...";
case "success":
return `Loaded: ${state.data}`;
case "error":
return `Failed: ${state.error}`;
default:
const exhaustiveCheck: never = state;
return exhaustiveCheck;
}
}
That default branch is a common trick: if every case is handled, state has type never inside default, so assigning it to a never-typed variable compiles cleanly. Add a new variant to RequestState later and forget to handle it in the switch, and this line fails to compile — the exhaustiveness check catches the gap at build time instead of leaving a silent runtime hole.
Where this pattern earns its keep
API responses. Modeling a fetch result as { status: "success", data } | { status: "error", error } instead of { data?, error? } makes invalid states — both data and error present, or neither — unrepresentable. The type itself documents the contract.
Reducer actions. A Redux-style reducer’s action union typically discriminates on a type field: { type: "increment" } | { type: "add"; amount: number }. The reducer’s switch statement narrows each action to exactly the payload it carries.
Parser and AST nodes. Compilers and parsers commonly discriminate on a kind field ({ kind: "literal"; value: number } | { kind: "binary"; op: string; left: Node; right: Node }), which is how tools like the TypeScript compiler itself represent syntax trees internally.
Discriminated unions vs enums
It’s worth distinguishing this pattern from a plain enum, since both involve a fixed set of named states:
| Discriminated union | Enum | |
|---|---|---|
| Carries data per variant | Yes — each variant can have its own fields | No — an enum member is just a label |
| Narrowing | Automatic via the discriminant field | N/A — doesn’t attach shape to a value |
| Runtime footprint | Zero (plain object literals) | enum (non-const) compiles to an object |
| Best for | Data with distinct shapes per case | A finite set of named constants |
If you need a fixed set of states without per-state data, plain union types of string literals or an enum will do. Discriminated unions earn their complexity when each state carries different data.
The takeaway
A discriminated union tags each branch of a union type with a shared literal field, and TypeScript uses that field to narrow the type automatically inside conditionals and switch statements — turning what would otherwise require manual casts or runtime checks into compiler-verified type safety. Reach for this pattern whenever you’re modeling “one of several distinct shapes” data, and add an exhaustiveness check in the default case so new variants can’t slip through unhandled.
Keep reading
Takina · · 4 min read TypeScript Abstract Classes, Explained
Abstract classes in TypeScript define shared implementation plus methods subclasses must fill in. How they differ from interfaces and when to reach for them.
Takina · · 3 min read TypeScript's never Type, Explained
never represents values that can't exist — it marks unreachable code, exhaustive switches, and functions that always throw or loop forever.
Takina · · 4 min read TypeScript readonly Modifiers Explained
TypeScript's readonly keyword blocks reassignment at compile time for properties, arrays, and tuples — with no runtime enforcement at all.