EduFlow

Section 1 · Types as a Design Tool

Discriminated Unions for Domain States

19 min3 min readCode playground

Lesson at a glance

Replace bags of optional fields with discriminated unions, so each order state carries exactly the data it has and impossible states stop compiling.

  1. 1One type per state
  2. 2Narrowing on the discriminant
  3. 3Transitions as functions between states
  4. 4Sharing common fields
  5. 5When not to use one

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Here is how Cartwheel’s Order type looked before anyone thought about it:

TypeScript
type Order = {
  id: number;
  status: 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
  trackingNumber?: string; // set once shipped
  deliveredAt?: string;    // set once delivered
  cancelReason?: string;   // set if cancelled
  refundCents?: number;    // set if returned
};

It looks reasonable, and it is a trap. Four optional fields can each be present or absent, so this type allows 5 × 2⁴ = 80 shapes. Cartwheel’s business has five. A shipped order with no tracking number compiles. A cancelled order with a delivery date compiles. And every time you read order.trackingNumber, you get string | undefined and reach for !, which is you telling the compiler “trust me” eighty times a week.

One type per state

A discriminated union gives each state its own object type and joins them with |. Each member has a shared property with a different literal value, the discriminant:

TypeScript
type ProcessingOrder = { id: number; status: 'processing' };
type ShippedOrder = { id: number; status: 'shipped'; trackingNumber: string };
type DeliveredOrder = { id: number; status: 'delivered'; trackingNumber: string; deliveredAt: string };
type CancelledOrder = { id: number; status: 'cancelled'; cancelReason: string };
type ReturnedOrder = {
  id: number;
  status: 'returned';
  trackingNumber: string;
  deliveredAt: string;
  refundCents: number;
};

type Order = ProcessingOrder | ShippedOrder | DeliveredOrder | CancelledOrder | ReturnedOrder;

Now the type allows exactly five shapes. Notice that nothing is optional any more: a shipped order has a tracking number. The impossible states are not checked at runtime; they are simply not expressible:

TypeScript
const o: Order = { id: 1042, status: 'shipped' };
// Error: Type '{ id: number; status: "shipped"; }' is not assignable to type 'Order'.
//   Property 'trackingNumber' is missing in type '{ id: number; status: "shipped"; }'
//   but required in type 'ShippedOrder'.
Order state machine. Processing goes to shipped via ship, shipped goes to delivered via deliver, delivered goes to returned via refund. Processing can also go to cancelled via cancel. Each state box lists the fields it adds. processing id shipped + trackingNumber delivered + deliveredAt returned + refundCents cancelled + cancelReason ship deliver refund cancel
Each state is its own type; transitions are functions between them

Narrowing on the discriminant

Checking the discriminant narrows the whole object. Inside each case, TypeScript knows exactly which member you have:

TypeScript
function describe(order: Order): string {
  switch (order.status) {
    case 'processing':
      return `#${order.id} is being packed`;
    case 'shipped':
      return `#${order.id} is on its way (${order.trackingNumber})`;
    case 'delivered':
      return `#${order.id} arrived on ${order.deliveredAt}`;
    case 'cancelled':
      return `#${order.id} was cancelled: ${order.cancelReason}`;
    case 'returned':
      return `#${order.id} was refunded ${(order.refundCents / 100).toFixed(2)}`;
  }
}

No !, no ?., no ?? ''. And notice the explicit : string return type. If someone adds an 'on-hold' status to the union next month, the end of this function becomes reachable, and TypeScript reports Function lacks ending return statement and return type does not include ‘undefined’. The compiler finds every place that needs updating. You will make this check explicit and louder with never in Branded Types and Exhaustive Checks.

Transitions as functions between states

The union pays off most in functions that change state. Type their inputs and outputs as specific variants:

TypeScript
function ship(order: ProcessingOrder, trackingNumber: string): ShippedOrder {
  return { id: order.id, status: 'shipped', trackingNumber };
}

function cancel(order: ProcessingOrder, reason: string): CancelledOrder {
  return { id: order.id, status: 'cancelled', cancelReason: reason };
}

Now “you cannot ship an order twice” and “you cannot cancel a shipped order” are compile-time facts. A caller with an Order in hand must narrow first:

TypeScript
function shipIfReady(order: Order, tracking: string): Order {
  if (order.status !== 'processing') return order;
  return ship(order, tracking); // order is ProcessingOrder here
}

The return types matter as much as the parameters. Because ship returns a ShippedOrder rather than an Order, the caller can hand the result straight to a function that needs a tracking number, such as printLabel(shipped), without checking status again. Each transition carries the knowledge forward. Return the wide Order and you throw that knowledge away, forcing every caller to narrow again.

Sharing common fields

Real orders have more than an id: a customer, a placed-at timestamp, line items. Repeating those in five variants invites drift, so pull them into a base type and intersect it with each state’s own fields:

TypeScript
type OrderBase = { id: number; customerId: number; placedAt: string };

type Shipped = OrderBase & { status: 'shipped'; trackingNumber: string };
type Cancelled = OrderBase & { status: 'cancelled'; cancelReason: string };

Each variant is still a separate member with its own literal status, so narrowing works exactly as before. The base type also gives you a natural parameter type for functions that do not care about the state, such as function customerLink(order: OrderBase), which accepts every variant.

You can also destructure the discriminant. Since TypeScript 4.4, const { status } = order; if (status === 'shipped') { … } narrows order too, as long as order is a const or an unassigned parameter. That keeps long switch statements readable without giving up the narrowing.

When not to use one

A discriminated union is right when states carry different data. When the variants differ only in a label (say, a channel of 'web' | 'mobile_app' | 'marketplace' with the same fields), a plain literal union on one property is enough. Reach for the full pattern when you catch yourself writing optional fields with comments like “only set when…”.

In the exercise, you will turn Cartwheel’s bag-of-optionals Order into a union and make ship accept only orders that can be shipped. Next lesson: literal types, as const and satisfies, the tools that keep those literals from widening back into string.

Key takeaways

  • A type made of optional fields allows every combination of them, most of which are states your business can never be in.
  • A discriminated union gives each state its own object type, tied together by a shared literal property such as status.
  • Checking the discriminant with === or switch narrows the whole object, so state-specific fields become available without !.
  • Functions that move between states should accept and return specific variants, like ship(order: ProcessingOrder): ShippedOrder.
  • An explicit return type on a switch over the discriminant turns a forgotten case into a compile error.

Further reading

Check your understanding

4 questions

Question 1 of 4

An Order type has a status with 5 values and 4 optional fields. Roughly how many shapes does the type allow, and how many of them are valid states if each status uses a fixed set of fields?

Question 2 of 4

Given the union below, what is the type of order inside the case 'returned': branch?

TypeScript
type Order =
  | { status: 'processing'; id: number }
  | { status: 'delivered'; id: number; deliveredAt: string }
  | { status: 'returned'; id: number; deliveredAt: string; refundCents: number };
Question 3 of 4

Which signature best uses the union to prevent shipping an order twice?

Question 4 of 4

You add an 'on-hold' status to the Order union. Which function is guaranteed to get a compile error until you handle it?

Practice

Code playground

Turn the order into a discriminated union

Replace the bag-of-optionals Order with a discriminated union of five variants (one per status), where every field a state has is required and fields it does not have are absent:

  • processing: id
  • shipped: id, trackingNumber
  • delivered: id, trackingNumber, deliveredAt
  • cancelled: id, cancelReason
  • returned: id, trackingNumber, deliveredAt, refundCents

Then change ship so it only accepts a processing order and returns a shipped one. The three @ts-expect-error lines must become real errors, and describe should no longer need !.

// Every field that only some statuses have is optional,
// so impossible states (shipped with no tracking number…) compile.
type Order = {
  id: number;
  status: 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
  trackingNumber?: string; // shipped, delivered, returned
  deliveredAt?: string;    // delivered, returned
  cancelReason?: string;   // cancelled
  refundCents?: number;    // returned
};

function ship(order: Order, trackingNumber: string): Order {
  return { id: order.id, status: 'shipped', trackingNumber };
}

function describe(order: Order): string {
  switch (order.status) {
    case 'processing': return `#${order.id} is being packed`;
    case 'shipped': return `#${order.id} is on its way (${order.trackingNumber})`;
    case 'delivered': return `#${order.id} arrived on ${order.deliveredAt}`;
    case 'cancelled': return `#${order.id} was cancelled: ${order.cancelReason}`;
    case 'returned': return `#${order.id} was refunded ${(order.refundCents! / 100).toFixed(2)}`;
  }
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<Order['status'], 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned'>>;

// @ts-expect-error: a shipped order must have a tracking number
const noTracking: Order = { id: 1, status: 'shipped' };

// @ts-expect-error: a cancelled order cannot have a delivery date
const cancelledButDelivered: Order = { id: 2, status: 'cancelled', cancelReason: 'Out of stock', deliveredAt: '2025-05-02' };

// @ts-expect-error: only processing orders can be shipped
ship({ id: 3, status: 'delivered', trackingNumber: '1Z-77', deliveredAt: '2025-05-02' }, '1Z-88');

console.log(describe(ship({ id: 1042, status: 'processing' }, '1Z-999')));
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Console

(no output)

Checks

  • The file type-checks with zero errors: all three impossible states are rejected
  • ship returns a shipped order with the tracking number and the same id
  • describe still describes returned and cancelled orders