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.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

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.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

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.

typescript — playgroundtype-checked
⌘/Ctrl + Enter to run

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

+50 XP on completion