Discriminated Unions in TypeScript
A discriminated union uses a shared literal property to tell TypeScript exactly which variant of a union you are working with. Learn the pattern, exhaustiveness checking, and when to use it.
A discriminated union is a union of object types where every member shares a common property with a unique literal value. That common property, called the discriminant, tells TypeScript exactly which member you are working with when you check its value.
Think of it as a tagged union. Each variant carries a tag, and TypeScript reads the tag to narrow the type safely.
interface Circle {
kind: "circle";
radius: number;
}
interface Square {
kind: "square";
sideLength: number;
}
type Shape = Circle | Square;Here, the kind field is the discriminant. Circle sets kind to "circle" and Square sets kind to "square". Checking that field tells TypeScript exactly which shape it is looking at.
Why the Pattern Exists
Without a discriminant, a plain union of objects is hard to work with safely. A common but flawed approach is to put every possible field on one type and mark them all optional:
interface BadShape {
kind: "circle" | "square";
radius?: number;
sideLength?: number;
}
function getArea(shape: BadShape) {
if (shape.kind === "circle") {
return Math.PI * shape.radius ** 2;
}
}Checking kind looks like it should narrow the type, but it does not, because radius and sideLength are both optional on the same type regardless of which kind is set:
'shape.radius' is possibly 'undefined'.TypeScript cannot know that radius is present just because kind is "circle", since nothing in the type actually links the two fields together. The discriminant does not narrow anything here.
The discriminated union fixes this by splitting each variant into its own type with its own required fields.
Narrowing with a Discriminant
Once each variant has its own type, checking the discriminant narrows to the exact member:
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.sideLength ** 2;
}
}
console.log(getArea({ kind: "circle", radius: 5 })); // 78.5398...
console.log(getArea({ kind: "square", sideLength: 4 })); // 16Inside the circle case, TypeScript knows shape is a Circle, so its radius field is accessible and typed as a number. Inside the square case, shape is narrowed to Square with its sideLength field instead.
The flow is: start with the union, check the discriminant, and TypeScript narrows to the specific variant in each branch.
Real-World Example: Network State
Discriminated unions model UI states well, since each state naturally carries its own data shape. A typical network request moves through four distinct states:
type NetworkState =
| { state: "idle" }
| { state: "loading" }
| { state: "success"; data: string[] }
| { state: "error"; message: string };Only the success variant has a data field, and only the error variant has a message field. Switching on the discriminant lets each branch use its own fields safely:
function render(state: NetworkState) {
switch (state.state) {
case "idle": return "Ready to fetch.";
case "loading": return "Loading...";
case "success": return `Loaded ${state.data.length} items.`;
case "error": return `Error: ${state.message}`;
}
}TypeScript enforces this at compile time: accessing the message field is rejected in any branch other than the one where state has already been narrowed to the error variant.
The state diagram shows which transitions are valid, and the discriminated union enforces which data is available in each state.
Exhaustiveness Checking
When a discriminated union grows, you want the compiler to flag any variant a switch statement forgot to handle. The never type makes this automatic, since a value can only be never once every real case has already been handled.
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`);
}Call this helper from a default case. As long as every real case returns early, the remaining value should already be narrowed to never by the time execution reaches the default branch:
function render(state: NetworkState): string {
switch (state.state) {
case "idle": return "Ready.";
case "loading": return "Loading...";
case "success": return `Loaded ${state.data.length} items.`;
case "error": return `Error: ${state.message}`;
default: return assertNever(state);
}
}The payoff shows up later, when someone adds a new variant to the union without remembering to update every function that switches on it. Imagine a teammate extends the type with a new "cached" state for offline data:
type NetworkState =
| { state: "idle" }
| { state: "loading" }
| { state: "success"; data: string[] }
| { state: "error"; message: string }
| { state: "cached"; retrievedAt: Date };The new cached variant is no longer covered by any case, so it still reaches the default branch. Since that value is not actually never, the call to the assertNever helper now fails to compile:
Argument of type '{ state: "cached"; retrievedAt: Date; }' is not assignable to parameter of type 'never'.The error points directly at the unhandled variant. Adding a matching cached case makes the error disappear. This is exhaustiveness checking: the compiler guarantees every variant gets handled, even as the union grows over time.
Choosing the Discriminant Name
Any shared literal property works as a discriminant. Common choices:
| Discriminant name | Use case |
|---|---|
kind | General-purpose variant labeling |
type | Action types, event types, message types |
status / state | UI states, process states |
tag | FP-style tagged unions |
The important rule: the discriminant value must be a unique literal type per variant. If two variants share the same discriminant value, narrowing cannot distinguish them.
// Bad: both have kind "shape"
type Bad =
| { kind: "shape"; radius: number }
| { kind: "shape"; sideLength: number };Multiple Discriminants
A discriminated union is not limited to one discriminant property. You can narrow on the main discriminant first, then check a second property once you know which variant you have:
type Event =
| { type: "click"; target: string }
| { type: "keypress"; key: string; modifier: boolean }
| { type: "scroll"; position: number };
function handle(event: Event) {
if (event.type === "keypress" && event.modifier) {
console.log(`Modified key: ${event.key}`);
}
}TypeScript narrows correctly through both checks, as long as the discriminant pattern is clear at each step.
Common Mistakes
Using a string instead of a string literal for the discriminant. If the discriminant is typed as a plain string, narrowing does not work because any string value is possible.
interface Loose {
kind: string; // Too broad -- narrowing won't work
radius: number;
}Always use a literal value for the discriminant field, not a broad string type.
Forgetting to handle every variant. Without a never-based exhaustiveness check, new variants silently fall through to the default case. Always add the assertNever pattern shown earlier.
Making every property optional instead of splitting into variants. The whole point of discriminated unions is to give each variant its own required fields. A single type with optional everything loses type safety.
Overusing discriminated unions for simple cases. If a value only has two variants that share most properties, a plain union with narrowing might be simpler. Use discriminated unions when each variant has meaningfully different data.
For more on the narrowing techniques that power discriminated unions, see type narrowing with typeof and custom type guards. The discriminated union pattern depends on the same narrowing engine.
Rune AI
Key Insights
- A discriminated union has a shared literal property (the discriminant) on every member.
- Checking the discriminant tells TypeScript exactly which member you have, unlocking its specific fields.
- Use switch statements on the discriminant for clean, exhaustive narrowing.
- Add a never-based default case to catch unhandled variants at compile time.
- The pattern works for network states, form steps, UI states, and any multi-variant data.
Frequently Asked Questions
What is a discriminated union in TypeScript?
What is the discriminant property?
How do I make sure I handle every variant of a discriminated union?
Conclusion
Discriminated unions turn a union of similar-looking objects into a safe, exhaustively-checked type. Give each member a unique literal discriminant, switch on it, and let TypeScript narrow for you. Add a never check in the default case to catch missed variants when the union grows.
More in this topic
TypeScript Compiler Explained
The TypeScript compiler (tsc) turns .ts files into .js files while checking types. Learn what it does, how to use it, and how it fits your workflow.
Set Up ESLint for TypeScript
Install and configure ESLint with typescript-eslint to catch bugs and enforce consistent code style in your TypeScript project.
Pick Utility Type in TypeScript
Pick creates a new type by selecting only the properties you name from an existing type. Use it to build focused types for specific views, API responses, or function parameters.