It looks like a duck…


Problem

Business uses the same noun to describe different aspects of reality. Let’s consider an order — an example that has already been discussed from every angle. You succumb to appearances and expand your model with a few new fields. After all, it’s all an order, why not add another field to it? Even if it looks like an order, it has fields like an order, it’s not anymore the same kind of order… I call it “uber-order problem”. Here’s an example:

interface UberOrder {
  id: string;
  userId: string;
  items: Item[];

  status: 'draft' | 'submitted' | 'shipped' | 'delivered';

  shippingAddress?: string;
  trackingNumber?: string;
  deliveredAt?: Date;
}

interface Item {
  productId: string;
  quantity: number;
}

Pros:

  • simple and centralized;
  • easy to persist in flat document stores (like MongoDB).

Cons:

  • weak type safety: trackingNumber may exist even if status is draft;
  • business rules are not enforced by the type system; a developer needs to be aware of enforcing them;
  • hard to reason about in code – you’ll constantly need if checks.
if (order.status === 'shipped' && order.trackingNumber) {
  // Valid… but what if `trackingNumber` is accidentally `undefined`?
}

Solution

Using discriminated unions, we model each stage as a distinct type:

interface DraftOrder {
  status: 'draft';
  id: string;
  userId: string;
  items: Item[];
}

interface SubmittedOrder {
  status: 'submitted';
  id: string;
  userId: string;
  items: Item[];
  shippingAddress: string;
}

interface ShippedOrder {
  status: 'shipped';
  id: string;
  userId: string;
  items: Item[];
  shippingAddress: string;
  trackingNumber: string;
}

interface DeliveredOrder {
  status: 'delivered';
  id: string;
  userId: string;
  items: Item[];
  shippingAddress: string;
  trackingNumber: string;
  deliveredAt: Date;
}

type Order = DraftOrder | SubmittedOrder | ShippedOrder | DeliveredOrder;

interface Item {
  productId: string;
  quantity: number;
}

Pros:

  • precise, strongly typed;
  • impossible to use trackingNumber before the order (status) is 'shipped';
  • enables clear transformation logic per stage;
  • encourages ubiquitous language: each type maps 1:1 to domain terms.

Cons:

  • more code;
  • harder to persist in a flat schema (but not a problem with serialization).