Advanced TypeScript for Enterprise Domain Models: Branded Types & Discriminated Unions

Primitive obsession in large codebases allows invalid domain data to permeate business logic unnoticed. Learn how to leverage nominal branding, discriminated unions, and exhaustiveness guards to enforce domain invariants at compile time.

Client Architecture: Structure frontend state layers that consume these types cleanly by reading modern React state architecture: TanStack Query vs. Zustand.

Primitive Obsession and the Anatomy of Silent Runtime Errors

TypeScript's type system is structurally typed: if two types share identical structures, TypeScript considers them completely interchangeable. While structural typing provides tremendous flexibility when integrating diverse libraries and JSON APIs, it introduces significant risks into enterprise domain modeling. The most pervasive symptom of this architectural flaw is known as Primitive Obsession.

Consider a typical e-commerce transaction function in TypeScript:

function processOrderRefund(userId: string, orderId: string, amountCents: number) {
  // Execute refund logic...
}

const currentUserId = "usr_991823";
const targetOrderId = "ord_448102";

// Silent bug! Arguments swapped, but TypeScript compiles with 0 errors:
processOrderRefund(targetOrderId, currentUserId, 5000);

Because both userId and orderId resolve structurally to string, the compiler cannot detect that the arguments have been transposed. This class of bug silently corrupts financial records, leaks customer data across tenants, and escapes unit test suites. Advanced enterprise TypeScript eliminates these defects by making invalid domain states impossible to represent.

1. Nominal Typing via Branded Types (Flavoring)

We can inject nominal identity into TypeScript's structural type system using Branded Types. By attaching a compile-time-only unique symbol brand, we transform generic primitive strings into strictly differentiated domain types with zero runtime memory overhead:

// src/types/brands.ts
declare const __brand: unique symbol;

export type Brand = T & { readonly [__brand]: B };

// Domain ID definitions
export type UserId = Brand;
export type OrderId = Brand;
export type Cents = Brand;

// Type-safe smart constructors
export function UserId(raw: string): UserId {
  if (!raw.startsWith("usr_")) throw new Error(`Invalid UserId format: ${raw}`);
  return raw as UserId;
}

export function OrderId(raw: string): OrderId {
  if (!raw.startsWith("ord_")) throw new Error(`Invalid OrderId format: ${raw}`);
  return raw as OrderId;
}

export function Cents(raw: number): Cents {
  if (!Number.isInteger(raw) || raw < 0) throw new Error(`Cents must be a positive integer: ${raw}`);
  return raw as Cents;
}

Now, attempt to compile the previous swapped invocation:

function processOrderRefund(userId: UserId, orderId: OrderId, amount: Cents) {
  // Guaranteed type safety and validated semantics
}

const user = UserId("usr_991823");
const order = OrderId("ord_448102");
const amount = Cents(5000);

// COMPILE ERROR: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
processOrderRefund(order, user, amount);

2. State Machine Modeling with Strict Discriminated Unions

Enterprise applications regularly deal with complex multi-step processes: payments, KYC verification, order fulfillment. A common anti-pattern is storing state with optional flags that allow contradictory states:

// ANTI-PATTERN: Allows contradictory state combinations
interface OrderPayment {
  status: "pending" | "processing" | "succeeded" | "failed";
  transactionId?: string;
  errorMessage?: string;
  refundedAmount?: number;
}
// An object with status="pending" AND transactionId="txn_123" is legally valid here,
// yet represents an impossible business state!

Instead, model the lifecycle as an explicit Discriminated Union where each state declares only the fields that can exist during that specific phase:

// DOMAIN-DRIVEN: Impossible states cannot be constructed
export type PaymentState =
  | { readonly status: "pending"; readonly initiatedAt: Date }
  | { readonly status: "processing"; readonly gatewayRef: string; readonly lockExpiresAt: Date }
  | { readonly status: "succeeded"; readonly transactionId: string; readonly paidAt: Date; readonly amount: Cents }
  | { readonly status: "failed"; readonly reason: string; readonly failedAt: Date; readonly retryable: boolean };

3. Compile-Time Exhaustiveness Guarantees with the never Type

When business requirements change and a new state is added (e.g. "requires_action" for 3D-Secure authentication), how do you guarantee that every switch statement across your backend handles the new state? Use the assertUnreachable exhaustiveness guard:

export function assertUnreachable(x: never): never {
  throw new Error(`Unhandled domain state encountered: ${JSON.stringify(x)}`);
}

export function handlePaymentTransition(payment: PaymentState): string {
  switch (payment.status) {
    case "pending":
      return `Awaiting gateway initiation since ${payment.initiatedAt.toISOString()}`;
    case "processing":
      return `Processing transaction under lock ref: ${payment.gatewayRef}`;
    case "succeeded":
      return `Payment settled! TXN: ${payment.transactionId} for ${payment.amount} cents`;
    case "failed":
      return `Payment rejected: ${payment.reason} (Retryable: ${payment.retryable})`;
    default:
      // If a new status is added to PaymentState, TypeScript will refuse to compile
      // here because 'x' will not be of type 'never'!
      return assertUnreachable(payment);
  }
}

4. Parse, Don't Validate

A central tenet of robust domain modeling is "Parse, Don't Validate": validation functions that return a boolean (isValidUser(u): boolean) require the caller to remember to check the boolean before proceeding. Parsing functions take unstructured data and return either a strongly-typed domain entity or an explicit failure:

export type Result =
  | { readonly ok: true; readonly value: T }
  | { readonly ok: false; readonly error: E };

export function parseEmail(raw: string): Result> {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailRegex.test(raw)) {
    return { ok: false, error: new Error(`Malformed email address: ${raw}`) };
  }
  return { ok: true, value: raw as Brand };
}
Architectural Continuity & Deep Dives

For related production architectures and system implementations, explore these companion guides:

Key Architectural Takeaways

  • Eliminate Primitive Obsession: Brand primitive types using unique symbols to prevent accidental parameter transposition at compile time.
  • Make Invalid States Unrepresentable: Model domain lifecycles with strict discriminated unions rather than wide interfaces with optional fields.
  • Enforce Exhaustiveness: Protect state switches with the never type pattern so that adding a domain state forces complete codebase alignment before compilation succeeds.
All Insights
Chat on WhatsApp