Discriminated Unions
Beginner · 21 min read · ▶ live playground · ✦ checkpoint
A discriminated union is a union of object types that all share one literal property, and that property is the discriminant, the field TypeScript checks to learn which object you have. This pattern is how you model "one of these shapes" without guessing which fields are safe.
You already know two pieces: literal unions from 4.1, and narrowing from 4.2. A discriminated union puts those pieces inside objects.
One shared tag, many object shapes
Most real data is not just "success" or "error". Each state has extra data attached. The trick is to give every member one shared literal property, often named kind, status, or type.
A switch statement is JavaScript syntax for choosing one branch by comparing one value against several case labels. Here, switch (delivery.kind) narrows the whole delivery object.
Inside case "email", TypeScript knows delivery is EmailDelivery, so delivery.subject is safe. Inside case "sms", delivery.subject is not even part of the set. One small tag unlocks the right payload.
Status is perfect for responses
An API response is data your program expects back from another system. This lesson models the type you want after that data is trusted or checked. A later lesson handles checking data when it first arrives.
This is stronger than { status: string; receiptId?: string; reason?: string; }. Optional fields make you ask "is this field really here?" over and over. A discriminated union says: when status is "ok", receiptId and totalCents are part of the promise.
never turns the compiler into a checklist
An exhaustive check is a check that proves every union member was handled. You use never, the empty set from 2.4, to make TypeScript complain when a new member enters the union but the switch is not updated.
This broken example adds "paused" but forgets its case:
type DraftCampaign = {
status: "draft";
title: string;
};
type ScheduledCampaign = {
status: "scheduled";
sendAt: string;
};
type SentCampaign = {
status: "sent";
sentAt: string;
};
type PausedCampaign = {
status: "paused";
reason: string;
};
type Campaign = DraftCampaign | ScheduledCampaign | SentCampaign | PausedCampaign;
function campaignLabel(campaign: Campaign): string {
switch (campaign.status) {
case "draft":
return "Draft: " + campaign.title;
case "scheduled":
return "Scheduled for " + campaign.sendAt;
case "sent":
return "Sent at " + campaign.sentAt;
default: {
const unhandled: never = campaign;
return unhandled;
}
}
}→ playground.ts:32:13 - error TS2322: Type 'PausedCampaign' is not assignable to type 'never'.
Read that message like a checklist. PausedCampaign reached the line that promised "nothing can reach here." The fix is not to hide the error. The fix is to add the missing "paused" case.
Events and app state use the same move
An event is a record that something happened. An app state is the current condition of a screen or workflow. Both are natural homes for discriminated unions.
The property name is not magic. Use kind when you are naming object kinds, status when you are naming process states, and type when you are naming events or actions. The magic is that every member has the same property, and each member gives it a different literal value.
Sometimes you do not control the shape, so no clean discriminant is waiting for you. Next lesson, you will learn how to narrow those messier values with in, instanceof, and user-defined type predicates.
Checkpoint
Answer all three to mark this lesson complete