Discriminated Unions
Discriminated Unions
Level 6 — Type Narrowing & Guards The most powerful and common pattern for typing complex state in TypeScript. It involves giving every object in a Union a shared, literal property (the "discriminant") used to easily narrow the types.
1. Prerequisites
- Union Types (
|) — The structure being narrowed. - Literal Types — The properties used as the discriminant.
2. Term Category
TypeScript Core Syntax (Tagged Union Pattern): Discriminated unions combine object variant types sharing a common literal property tag for pattern matching and type narrowing.
3. Explanation
Environment Context
- Compile-Time
(1) Design Motivation — "Why did we design this?"
Using the in operator or Custom Type Guards works fine for simple unions (Bird | Fish). But what if you have a complex union of 10 different Event types, or 5 different API response states (Loading, Success, Error)?
Checking "data" in response or "errorMessage" in response becomes extremely messy and hard to read.
Discriminated Unions solve this by forcing every object in the union to share a single, identical property name (usually type, kind, or status). The value of this property is a strict Literal Type.
(2) The Pattern
Notice how all three interfaces share the exact same property name: status.
interface LoadingState {
status: "loading"; // Literal Type!
}
interface SuccessState {
status: "success"; // Literal Type!
data: string[];
}
interface ErrorState {
status: "error"; // Literal Type!
errorMessage: string;
}
type APIState = LoadingState | SuccessState | ErrorState;
(3) The Magic of the Switch Statement
Because all objects in the Union share the status property, TypeScript allows you to read status without any narrowing.
Once you use an if or switch statement on the discriminant (status), TypeScript instantly narrows the entire object!
function renderUI(state: APIState) {
switch (state.status) {
case "loading":
// Narrowed to LoadingState
return "Loading...";
case "success":
// Narrowed to SuccessState! Safe to access `data`
return `Loaded ${state.data.length} items`;
case "error":
// Narrowed to ErrorState! Safe to access `errorMessage`
return `Error: ${state.errorMessage}`;
}
}
4. Common Mistakes & Pitfalls
Mistake 1: Forgetting the literal type
The mistake: A developer writes:
interface Success { status: string; data: string; }
interface Error { status: string; code: number; }
Why it's wrong: The status property is just a generic string. If you check if (state.status === "success"), TypeScript won't narrow anything, because any generic string can theoretically equal "success".
Golden Rule: The discriminant property MUST be typed as a specific Literal Type (e.g., "success"), an Enum, or a boolean. It cannot be a generic primitive.
Mistake 2: Using Non-Literal or Dynamic Types as Discriminant Properties
The mistake: Using kind: string as discriminant field in type Shape = { kind: string; radius: number } | ....
Why it's wrong: Discriminant properties MUST be literal types (e.g. kind: "circle"). A general string type cannot uniquely identify union variants.
Incorrect:
type A = { type: string; a: number };
type B = { type: string; b: string };
// TS cannot discriminate variant by string property!
Fix:
type A = { type: "A"; a: number };
type B = { type: "B"; b: string };
// TS discriminates via 'A' vs 'B' literal tags
Mistake 3: Mismatched Discriminant Field Property Names across Variants
The mistake: Using kind: "a" in variant A and type: "b" in variant B.
Why it's wrong: Discriminant properties must share the exact same property key name across all variants in the union.
Incorrect:
type VariantA = { type: "A"; val: number };
type VariantB = { kind: "B"; str: string };
Fix:
type VariantA = { type: "A"; val: number };
type VariantB = { type: "B"; str: string };
5. Practice Exercises
Exercise 1: Pattern Matching Discriminated Unions
Scenario:
Define a Shape discriminated union containing Square, Rectangle, and Circle variants with a common kind literal tag.
Requirements:
- Create variants sharing
kinddiscriminant string literal. - Implement area calculator using
switch(shape.kind).
Answer
Implementation
type Square = { kind: "square"; size: number };
type Rectangle = { kind: "rectangle"; width: number; height: number };
type Circle = { kind: "circle"; radius: number };
type Shape = Square | Rectangle | Circle;
function calculateArea(shape: Shape): number {
switch (shape.kind) {
case "square":
return shape.size * shape.size;
case "rectangle":
return shape.width * shape.height;
case "circle":
return Math.PI * shape.radius ** 2;
}
}
Technical Explanation
- Discriminated unions share a common single-valued property tag (
kind) across all variants. switch (shape.kind)narrowsshapeto its exact constituent type in eachcaseblock.- Fundamental pattern for domain modeling and state management.
Exercise 2: Modeling Asynchronous State Machines
Scenario:
Create an asynchronous HTTP state discriminated union (IdleState, LoadingState, SuccessState<T>, ErrorState).
Requirements:
- Use
statusdiscriminant tag across 4 state variants.
Answer
Implementation
type IdleState = { status: "idle" };
type LoadingState = { status: "loading" };
type SuccessState<T> = { status: "success"; data: T };
type ErrorState = { status: "error"; error: string };
type AsyncState<T> = IdleState | LoadingState | SuccessState<T> | ErrorState;
function renderState(state: AsyncState<string[]>) {
switch (state.status) {
case "idle": return "Click to load";
case "loading": return "Loading items...";
case "success": return `Loaded ${state.data.length} items`;
case "error": return `Error: ${state.error}`;
}
}
Technical Explanation
- Discriminated unions prevent invalid state combinations (e.g.
loading: trueanderror: "Failed"simultaneously). - Guarantees that data payload properties (
data,error) exist ONLY when the correspondingstatustag matches. - Standard architecture for UI state management (React
useReducer, Redux).
Exercise 3: Nested Discriminants and Composite Tags
Scenario:
Demonstrate narrowing on nested discriminant properties like event.payload.type.
Requirements:
- Narrow nested discriminant
action.meta.type.
Answer
Implementation
type UserEvent =
| { meta: { type: "USER_LOGIN" }; userId: string }
| { meta: { type: "USER_LOGOUT" }; timestamp: number };
function handleEvent(event: UserEvent) {
if (event.meta.type === "USER_LOGIN") {
console.log("Logged in user:", event.userId);
} else {
console.log("Logged out at:", event.timestamp);
}
}
Technical Explanation
- TypeScript control-flow analysis can narrow unions using nested property tags (
event.meta.type). - Simplifies deep event routing and payload handling.
- Highly versatile discriminant pattern.
6. Related Terms
- Literal Types — The building blocks of the discriminant.
void&never—neveris used in thedefaultcase of a Discriminated Union switch statement for exhaustive checking.- Exhaustiveness Checking (
never) — Related concept: Exhaustiveness Checking (never). inOperator Narrowing — Related concept:inOperator Narrowing.- Union Types (
|) — Union types. - Type Narrowing — Related concept: Type Narrowing.
7. Key Takeaways
- A Discriminated Union is a union of object types that all share a common property (the "discriminant").
- The discriminant property must be a unique Literal Type for each object (e.g.,
kind: "circle"vskind: "square"). - It is the cleanest, most scalable way to narrow complex state, especially when using
switchstatements. - This pattern is universally used in TypeScript architecture, heavily featuring in state machines, API responses, and Redux reducers.