Discriminated Unions
One tag to rule them all — and in the darkness, bind them to their shape.
Discriminated unions (also called "tagged unions") are one of TypeScript's most powerful patterns. The idea is beautifully simple: you create a union of object types where every object shares a common property, but each type gives that property a different literal value. That shared property is called the discriminant or tag.
Think of food delivery apps on your phone — Swiggy, Zomato, Dunzo. Each delivery person arrives at your door differently. The Swiggy rider wears orange, the Zomato rider wears red, and the Dunzo rider wears green. The uniform color is your discriminant — the tag that tells you instantly which service arrived. You don't need to ask "which app are you from?" — just look at the color!
Similarly, in a discriminated union, each type carries a tag like status: "success" versus status: "error". TypeScript reads that tag and instantly knows the full shape of the object. No typeof checks, no in operator guessing — just check the tag and TypeScript narrows the type automatically. This pattern is the backbone of safe, predictable code in real-world TypeScript applications, from API responses to state management to form handling. Once you learn it, you'll see discriminated unions everywhere — and wonder how you ever wrote TypeScript without them.
A discriminated union requires exactly three things:
- Multiple object types combined into a union
- A shared property name — the discriminant — present in every type
- Different literal values for that discriminant in each type
The classic example is an API response. A request can succeed or fail, and the shape of the response is different in each case:
type SuccessResp = {
status: "success";
data: string;
};
type ErrorResp = {
status: "error";
message: string;
};
type ApiResponse =
| SuccessResp
| ErrorResp;
Notice how both SuccessResp and ErrorResp share the status property, but SuccessResp has status: "success" while ErrorResp has status: "error". That's the discriminant at work. The ApiResponse type combines them with a union.
Without the discriminant pattern, TypeScript would only know that a value of type ApiResponse has a status property of type "success" | "error", but it wouldn't know which other properties are available. With the discriminant, checking status gives TypeScript enough information to narrow the type and give you full access to the correct properties. The literal types are the secret sauce — they're not just string, they're specific string values that act as type-level signals. Think of each literal as a unique fingerprint that TypeScript can match against.
The real magic of discriminated unions happens during narrowing. When you check the discriminant's value in a conditional or switch statement, TypeScript automatically narrows the union down to the matching type. This is far more reliable than using typeof or in checks because the discriminant is explicitly designed for this purpose.
function handleResponse(
res: ApiResponse
) {
if (res.status === "success") {
// TS knows: res is SuccessResp
console.log(res.data);
}
if (res.status === "error") {
// TS knows: res is ErrorResp
console.log(res.message);
}
}
In the handleResponse function above, when we check res.status === "success", TypeScript narrows res from ApiResponse to SuccessResp. Inside that block, res.data is available and type-safe. Similarly, checking res.status === "error" narrows to ErrorResp, giving access to res.message.
This works because TypeScript has special narrowing logic for discriminant properties. It's not just any property check — TypeScript recognizes that the literal types on the discriminant create an exclusive mapping. Each literal value corresponds to exactly one type in the union. That's why it's called "discriminated" — the discriminant discriminates (distinguishes) between the types. You can use if statements, switch statements, or even ternary expressions — any control flow that checks the discriminant value will trigger narrowing. This makes your code both safer and more readable compared to ad-hoc type checking with typeof or the in operator.
Discriminated unions shine in real-world code. One of the most common patterns is modeling async state — think of a component that can be idle, loading, successfully loaded, or in an error state. Each state has different data attached:
type FetchIdle = {
state: "idle";
};
type FetchLoading = {
state: "loading";
};
type FetchSuccess = {
state: "success";
data: string[];
};
type FetchError = {
state: "error";
error: string;
};
type FetchState =
| FetchIdle
| FetchLoading
| FetchSuccess
| FetchError;
Now when you render your UI, you check state.state (the discriminant) and TypeScript gives you access to exactly the right properties:
function renderUI(
state: FetchState
) {
switch (state.state) {
case "idle":
return "Click to load";
case "loading":
return "Loading...";
case "success":
return state.data.join(", ");
case "error":
return "Oops: " + state.error;
}
}
Another common pattern is modeling actions in a state management system like Redux. Each action is a discriminated union where the type property is the discriminant:
type Action =
| { type: "ADD"; payload: string }
| { type: "DELETE"; id: number }
| { type: "CLEAR" };
In a reducer, checking action.type narrows to the correct action shape, giving you type-safe access to action.payload or action.id. This pattern scales beautifully — add a new action type, and TypeScript will flag every place you forgot to handle it. The discriminated union pattern turns your type system into a compiler-verified state machine, catching bugs before they reach your users.
One of the most powerful techniques with discriminated unions is exhaustiveness checking. When you handle all possible cases in a switch statement, you can use the never type to verify that no case was missed. If you later add a new type to the union but forget to handle it, TypeScript will produce a compile error.
type Circle = {
kind: "circle";
radius: number;
};
type Square = {
kind: "square";
side: number;
};
type Triangle = {
kind: "triangle";
base: number;
height: number;
};
type Shape =
| Circle
| Square
| Triangle;
The assertNever function is the key tool here. It accepts a parameter of type never — a type that should never be reachable:
function assertNever(
x: never
): never {
throw new Error(
"Unhandled case: " + x
);
}
function getArea(shape: Shape) {
switch (shape.kind) {
case "circle":
return Math.PI *
shape.radius ** 2;
case "square":
return shape.side ** 2;
case "triangle":
return 0.5 *
shape.base *
shape.height;
default:
return assertNever(shape);
}
}
If the switch handles all cases, the default branch is unreachable, and shape is narrowed to never — everything compiles fine. But if you add a new shape type (like Rectangle) and forget to add a case, the default branch becomes reachable, and shape is narrowed to Rectangle (not never). Since Rectangle is not assignable to never, TypeScript throws a compile error!
This technique is invaluable in large codebases. When your discriminated union has 10+ variants, exhaustiveness checking ensures that adding a new variant forces you to update every handler. Without it, silent bugs creep in — a new action type goes unhandled, a new state is ignored, and your app misbehaves at runtime. With assertNever, the compiler becomes your safety net, catching every missed case before the code even runs.
Key Takeaways
- A discriminated union is a union of object types sharing a common property with different literal values — that property is the discriminant (tag)
- Checking the discriminant value in if/switch narrows the type to the matching variant automatically
- Literal types on the discriminant are the secret — plain string won't work, you need "success" | "error" not string
- Real-world uses: API responses, async state machines, Redux actions, form validation states, shape calculations
- Use assertNever with the never type for exhaustiveness checking — catch missed cases at compile time, not runtime
Want to track your progress?
Log in to save your place and pick up where you left off.
Progress track karna chahte ho?
Login karo apni progress save karne ke liye aur jahan chhoda tha wahan se shuru karo.
Login