Discriminated Unions at Scale
You met discriminated unions in Unions & Narrowing: a union of object types that share one literal property, the tag, so a switch on the tag tells TypeScript exactly which member you have. Small examples with three payment modes are easy. Real applications have screens with five loading states, event buses with forty event types, and reducers that four developers edit in the same week.
This lesson shows the patterns teams use to keep large unions safe: modelling state so impossible combinations cannot exist, a reusable exhaustiveness helper, handler maps that the compiler checks for completeness, families of events picked with Extract, and the places where narrowing quietly stops working.
Model states, not flags
A common first attempt at a "load data" screen uses separate flags:
type ResultsScreen = {
loading: boolean;
error?: string;
data?: { rollNo: number; marks: number }[];
};This type allows nonsense: loading: true together with an error, or no loading, no error and no data. Every component that reads it must guess which combination is "real". With four optional fields there are sixteen combinations, and only four make sense.
Give each real state its own member instead. Each member carries exactly the data that exists in that state:
type Mark = { rollNo: number; marks: number };
type ResultsState =
| { status: "idle" }
| { status: "loading"; startedAt: number }
| { status: "loaded"; rows: Mark[] }
| { status: "failed"; reason: string; retries: number };
function render(state: ResultsState): string {
switch (state.status) {
case "idle":
return "Choose a class to see results";
case "loading":
return `Loading since ${state.startedAt} ms`;
case "loaded": {
const top = state.rows.toSorted((a, b) => b.marks - a.marks)[0];
return top ? `Top: roll ${top.rollNo} with ${top.marks}` : "No results yet";
}
case "failed":
return `Error: ${state.reason} (tried ${state.retries} times)`;
}
}
const states: ResultsState[] = [
{ status: "idle" },
{ status: "loading", startedAt: 120 },
{ status: "loaded", rows: [{ rollNo: 4, marks: 81 }, { rollNo: 9, marks: 94 }] },
{ status: "failed", reason: "Server busy", retries: 2 },
];
states.forEach((s) => console.log(render(s)));Choose a class to see results Loading since 120 ms Top: roll 9 with 94 Error: Server busy (tried 2 times)
The code that changes state also becomes clearer. Moving from "loading" to "loaded" means building a new object with rows; you cannot forget to clear an old error, because the "loaded" member has no error property at all. This idea is often called "make impossible states impossible".
One exhaustiveness helper for the whole codebase
In a large project you do not want to write const unhandled: never = x in every switch. Put a tiny helper in a shared file and call it from every default branch:
export function assertNever(value: never, label = "value"): never {
throw new Error(`Unhandled ${label}: ${JSON.stringify(value)}`);
}
type Seat = { berth: "lower" } | { berth: "middle" } | { berth: "upper" } | { berth: "side-lower" };
function berthCode(seat: Seat): string {
switch (seat.berth) {
case "lower": return "LB";
case "middle": return "MB";
case "upper": return "UB";
default: return assertNever(seat, "berth");
}
}app.ts(12,33): error TS2345: Argument of type '{ berth: "side-lower"; }' is not assignable to parameter of type 'never'.The helper does two jobs. At compile time, the parameter type never turns a forgotten case into an error that names the missing member. At run time, it throws a clear message if bad data slips in from outside (for example an old app version sending "side-upper"), instead of silently returning undefined.
Handler maps: exhaustive by construction
When a union grows to dozens of members, a giant switch becomes hard to review. A popular alternative is an object with one handler per tag. A mapped type (see Mapped Types) makes the compiler demand a handler for every tag, and gives each handler the exact member type:
type ShopEvent =
| { type: "order.placed"; orderId: string; amount: number }
| { type: "order.cancelled"; orderId: string; reason: string }
| { type: "payment.received"; orderId: string; upiRef: string }
| { type: "payment.failed"; orderId: string; code: number };
type Handlers = {
[K in ShopEvent["type"]]: (event: Extract<ShopEvent, { type: K }>) => string;
};
const handlers: Handlers = {
"order.placed": (e) => `New order ${e.orderId} for ₹${e.amount}`,
"order.cancelled": (e) => `Order ${e.orderId} cancelled: ${e.reason}`,
"payment.received": (e) => `Paid ${e.orderId}, ref ${e.upiRef}`,
"payment.failed": (e) => `Payment for ${e.orderId} failed with code ${e.code}`,
};
function dispatch<E extends ShopEvent>(event: E): string {
const handler = handlers[event.type] as (e: E) => string;
return handler(event);
}
console.log(dispatch({ type: "order.placed", orderId: "JP-101", amount: 1299 }));
console.log(dispatch({ type: "payment.failed", orderId: "JP-101", code: 51 }));New order JP-101 for ₹1299 Payment for JP-101 failed with code 51
Extract<ShopEvent, { type: K }> picks the one member whose type is K, so inside the "payment.failed" handler e.code is a number without any switch. If someone adds "order.shipped" to the union, the handlers object stops compiling until a handler is written. That is the same safety as assertNever, spread across a team.
The one as inside dispatch is needed because TypeScript cannot connect "the handler chosen by event.type" with "the type of event" on its own. Keep such casts inside one small, tested function, so the rest of the code stays free of them.
Working with families of members
Tags with a common prefix, like "payment.received" and "payment.failed", let you select a whole family with a template literal type (see Template Literal Types):
type ShopEvent =
| { type: "order.placed"; orderId: string; amount: number }
| { type: "payment.received"; orderId: string; upiRef: string }
| { type: "payment.failed"; orderId: string; code: number };
type PaymentEvent = Extract<ShopEvent, { type: `payment.${string}` }>;
function isPaymentEvent(e: ShopEvent): e is PaymentEvent {
return e.type.startsWith("payment.");
}
const log: ShopEvent[] = [
{ type: "order.placed", orderId: "PN-7", amount: 450 },
{ type: "payment.failed", orderId: "PN-7", code: 51 },
{ type: "payment.received", orderId: "PN-7", upiRef: "UPI9921" },
];
const payments = log.filter(isPaymentEvent);
console.log(payments.map((e) => e.type));[ 'payment.failed', 'payment.received' ]
PaymentEvent is now a smaller union you can pass to code that only deals with payments, such as a reconciliation report. Because it is derived from ShopEvent, it updates itself when new payment events are added.
Where narrowing stops working
Narrowing follows your code's control flow, and a few patterns break that flow. These are the ones that confuse experienced developers too.
Destructuring with let. If every member has a field with the same name, you can destructure the tag and that field with const and still narrow: with const { unit, value } = reading, the check if (unit === "celsius") makes value a number. Write the same line with let and the link is lost, because either variable could be reassigned. A check saved in a const, such as const isCelsius = reading.unit === "celsius"; if (isCelsius) ..., also narrows reading.
Callbacks after reassignment. TypeScript keeps a narrowed type inside a callback only when the variable cannot change afterwards. A let that is reassigned later loses its narrowing inside the arrow function:
type Ticket = { kind: "confirmed"; coach: string } | { kind: "waiting"; position: number };
declare function loadTicket(): Ticket;
let ticket = loadTicket();
if (ticket.kind === "confirmed") {
setTimeout(() => console.log(ticket.coach), 1000);
}
ticket = loadTicket();app.ts(6,39): error TS2339: Property 'coach' does not exist on type 'Ticket'.
Property 'coach' does not exist on type '{ kind: "waiting"; position: number; }'.The compiler is right: by the time the timer fires, ticket may hold a waiting-list ticket. Copy the narrowed value into a const before the callback (const confirmed = ticket;), or avoid reassigning.
Checks hidden in other functions. if (isCancelled(order)) narrows only when isCancelled is a type guard with a value is Type return type, as you saw in Type Guards & Assertions. A plain boolean function teaches the compiler nothing.
Optional tags. If the tag is optional on one member (status?: "draft"), switch narrowing becomes unreliable. Make the tag required on every member, even if a member has nothing else.
Common mistakes
- Using
stringas the tag type.type: stringcannot discriminate anything. Tags must be literal types. - Adding a
defaultthat returns a fallback value. It hides newly added members. UseassertNeverindefaultso new members cause compile errors. - Sharing a field name with different meanings.
amountin rupees on one member and in paise on another invites bugs. Keep field meanings consistent across members. - Building the union by hand twice. Derive smaller unions with
ExtractandExcludefrom one source type, so they cannot drift apart.
Key points
- Model each real state as its own union member, so impossible combinations cannot be written.
- A shared
assertNever(value: never)helper gives compile-time exhaustiveness and a clear run-time error. - A mapped handler type over
Union["type"]withExtractforces a handler for every tag. Extractwith a template literal tag pattern, such aspayment.${string}, selects a family of members from a larger union.- Narrowing is lost when a
letis reassigned after a callback is created, or when a check is a plain boolean function.
Exercise
A food delivery app in Lucknow tracks orders with a flag-based type { placed: boolean; riderName?: string; deliveredAt?: string; cancelReason?: string }. Replace it with a discriminated union Delivery with four states: "placed", "picked" (with riderName), "delivered" (with riderName and deliveredAt) and "cancelled" (with cancelReason). Write a Handlers object type that requires a message function for every state, implement it, and print a message for one order in each state.
Show solution
type Delivery =
| { state: "placed"; orderId: string }
| { state: "picked"; orderId: string; riderName: string }
| { state: "delivered"; orderId: string; riderName: string; deliveredAt: string }
| { state: "cancelled"; orderId: string; cancelReason: string };
type Handlers = {
[K in Delivery["state"]]: (d: Extract<Delivery, { state: K }>) => string;
};
const messages: Handlers = {
placed: (d) => `${d.orderId}: restaurant is preparing your food`,
picked: (d) => `${d.orderId}: ${d.riderName} is on the way`,
delivered: (d) => `${d.orderId}: delivered by ${d.riderName} at ${d.deliveredAt}`,
cancelled: (d) => `${d.orderId}: cancelled (${d.cancelReason})`,
};
const orders: Delivery[] = [
{ state: "placed", orderId: "LKO-1" },
{ state: "picked", orderId: "LKO-2", riderName: "Farhan" },
{ state: "delivered", orderId: "LKO-3", riderName: "Isha", deliveredAt: "8:42 pm" },
{ state: "cancelled", orderId: "LKO-4", cancelReason: "address not found" },
];
for (const o of orders) {
const fn = messages[o.state] as (d: typeof o) => string;
console.log(fn(o));
}Each state carries only its own fields, and adding a fifth state makes messages fail to compile until a handler is written.
Quick Quiz
1. Why is { loading: boolean; error?: string; data?: T } a weak model for screen state?
Separate flags multiply into many combinations; a union lists only the valid ones.
2. What does Extract<ShopEvent, { type: "payment.failed" }> give you?
Extract keeps the union members assignable to the second type.
3. Why does assertNever(value: never) report a compile error when a case is missing?
Only when every member is handled has the value's type shrunk to never.
4. In which situation does TypeScript drop narrowing inside an arrow function?
If the variable can change later, the callback might see a different member.
Learn TypeScript with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
