Discriminated Unions: Modelling Variants Safely
A discriminated union is the idiomatic TypeScript way to model "one of several shapes", using a shared tag field that tells the shapes apart.
What you will learn
- Understand the problem plain unions of objects cause
- Add a discriminant tag so TypeScript can narrow safely
- Handle every case with a switch on the tag
The problem: a union of object shapes
Real apps constantly have a value that is "one of several kinds". An API response is either a success or an error. A shape is a circle, a square, or a rectangle. A payment is cash, card, or UPI. Each kind carries *different* data.
You might reach for a plain union of objects — but TypeScript cannot tell which shape you are holding, so it blocks you from touching the kind-specific fields. A discriminated union (also called a tagged union) fixes this with one simple trick.
The trick: a shared "tag" field
Give every member of the union a property with the same name but a different literal value — this is the discriminant (the tag). By convention it is often called kind or type. Because each tag value is unique, checking it tells TypeScript exactly which shape you have.
interface Circle {
kind: 'circle'; // the tag
radius: number;
}
interface Square {
kind: 'square'; // the tag
side: number;
}
type Shape = Circle | Square;Note: Output:
(No output — these are type definitions.) Both shapes share a kind property, but with different literal values ('circle' vs 'square'). That difference is what lets TypeScript narrow the union.
Switch on the tag and the fields unlock
Now write a function that takes a Shape. Inside a switch on kind, TypeScript narrows the value to exactly one member in each branch — so the right fields become available with full safety:
function area(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// here TypeScript KNOWS shape is a Circle
return Math.PI * shape.radius * shape.radius;
case 'square':
// here TypeScript KNOWS shape is a Square
return shape.side * shape.side;
}
}
console.log(area({ kind: 'circle', radius: 10 }).toFixed(2));
console.log(area({ kind: 'square', side: 5 }));Note: Output:
314.16
100
In the 'circle' branch TypeScript narrows shape to Circle, so shape.radius is allowed. In the 'square' branch it is a Square, so shape.side is allowed. Try reading shape.side in the circle branch and TypeScript reports an error — the tag protects you.
A realistic example: an API result
This pattern is everywhere in real code. An API call either succeeds (with data) or fails (with a message). Model it as a tagged union and the caller is forced to handle both:
type ApiResult =
| { status: 'success'; data: string[] }
| { status: 'error'; message: string };
function show(result: ApiResult): string {
if (result.status === 'success') {
return 'Loaded ' + result.data.length + ' items';
}
return 'Failed: ' + result.message;
}
console.log(show({ status: 'success', data: ['a', 'b', 'c'] }));
console.log(show({ status: 'error', message: 'Network down' }));Note: Output:
Loaded 3 items
Failed: Network down
Checking result.status === 'success' narrows to the success shape, so result.data exists there; the other branch has result.message. You can never accidentally read data on an error — TypeScript would reject it.
How to build one, step by step
- List every variant your value can be (e.g. circle, square; success, error).
- Give each variant a shared tag property with the same name (
kindorstatusetc.). - Give that tag a unique literal value per variant (
'circle','square', ...). - Join the variants with
|into one union type. - In your code,
switch(orif) on the tag — each branch is automatically narrowed to one variant.
Tip: The discriminated union is the standard, idiomatic way to model state and variants in TypeScript. You will see it constantly: Redux actions (type: 'ADD' | type: 'REMOVE'), reducer states (status: 'loading' | 'loaded' | 'failed'), and API responses all use it.
Watch out: The tag must be a literal type ('circle'), not a plain string. If you write kind: string on every member, every member looks the same to TypeScript and narrowing stops working. Unique literals are what make the magic happen.
Q. What makes a union of object types a *discriminated* union?
✍️ Practice
- Add a third member
Rectangle { kind: 'rectangle'; width: number; height: number }to the Shape union and extend the area function. - Model a
Notificationtagged union with members'email'(to: string) and'sms'(phone: string), and a function that returns a send message for each.
🏠 Homework
- Design a
PaymentResulttagged union with'paid'(amount: number) and'declined'(reason: string) members. Write a function that returns a friendly message for each, and show both outputs.