Open chat

← All posts

TypeScriptarchitecture

Domain modeling in TypeScript at scale

Using discriminated unions and nominal typing so invalid states are unrepresentable—and refactors stay mechanical.

Enterprise TypeScript stays maintainable when domain rules live in types, not tribal knowledge. Two patterns I standardize on are discriminated unions for lifecycle states and branded primitives for identifiers.

Discriminated unions encode workflows

When an entity’s legal fields depend on its phase, a wide interface with optional properties forces defensive checks everywhere. A union ties each status to the fields that may exist:

type OrderPending = {
  readonly status: "pending";
  readonly createdAt: string;
};

type OrderPaid = {
  readonly status: "paid";
  readonly createdAt: string;
  readonly paidAt: string;
  readonly paymentIntentId: string;
};

type Order = OrderPending | OrderPaid;

function shipLabel(order: Order): string | null {
  if (order.status !== "paid") return null;
  return `label:${order.paymentIntentId}`;
}

Exhaustive switch on status satisfies the compiler and documents the contract for reviewers.

Branded types for opaque identifiers

Primitive obsession (string everywhere) allows mixing userId, tenantId, and raw tokens. Lightweight branding preserves ergonomics while preventing accidental substitution:

declare const brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [brand]: B };

type UserId = Brand<string, "UserId">;
type TenantId = Brand<string, "TenantId">;

function assertUserId(id: string): UserId {
  return id as UserId;
}

Boundary parsing

External payloads should be validated at the edge (e.g. with a schema library), then mapped into these internal types. That keeps domain types stable when vendor JSON drifts.

Takeaway: Invest in the type system up front; the return is faster refactors, safer APIs, and less time spent in runtime guards.

← Back to portfolio