Making Illegal States Unrepresentable in TypeScript

3 september 2026

Introduction: The Cost of Permissive Types

In traditional OOP and basic TypeScript, we often model domain entities using wide primitive types (like raw string or number) and optional fields ({ status?: string, error?: string }). This creates loose contracts where impossible application states are valid according to the compiler, forcing developers to write defensive runtime checks everywhere.

By combining two functional programming concepts, Branded Types and Algebraic Data Types (ADTs), we can push validation to the boundaries and ensure invalid states fail at compile time.

Part 1: Domain Primitives with Branded Types

The Problem: Primitive Obsession

TypeScript uses a structural type system. Two types with the same shape are interchangeable, which allows bugs like swapping two UUID strings to go completely unnoticed by the compiler:

// Dangerous: Both are just strings
const assignProject = (userId: string, projectId: string) => { /* ... */ };

const userId = "usr_123";
const projectId = "prj_456";

// Compiles fine, but runtime logic breaks
assignProject(projectId, userId);

The Solution: Nominal Branding

A unique type brand makes structurally identical primitives incompatible. These are the IDs we will carry through the rest of the project management example:

declare const __brand: unique symbol;

type Brand<K, T> = K & { readonly [__brand]: T };

// Distinct domain types
export type UserId = Brand<string, "UserId">;
export type ProjectId = Brand<string, "ProjectId">;
export type Project = {
  readonly id: ProjectId;
  readonly ownerId: UserId;
  readonly name: string;
};

Smart Constructors (Validation at the Boundary)

Instead of casting raw strings arbitrarily, create constructor functions that enforce business invariants once at the API boundary. The validated IDs can then be passed to the rest of the application:

export const parseUserId = (input: string): UserId => {
  if (!/^usr_[a-z0-9]+$/.test(input)) {
    throw new Error("Invalid user ID: " + input);
  }
  return input as UserId;
};

export const parseProjectId = (input: string): ProjectId => {
  if (!/^prj_[a-z0-9]+$/.test(input)) {
    throw new Error("Invalid project ID: " + input);
  }
  return input as ProjectId;
};

const userId = parseUserId("usr_123");
const projectId = parseProjectId("prj_456");

assignProject(userId, projectId);

// Compile Error: ProjectId is not assignable to UserId
assignProject(projectId, userId);

Part 2: Eliminating State Ambiguity with Tagged Unions (Sum Types)

The Anti-Pattern: The Kitchen-Sink State

A common way to model asynchronous UI state:

// Anti-Pattern: Allows impossible combinations
type AsyncState<T> = {
  isLoading: boolean;
  data?: T;
  error?: string;
};

// An impossible project state that TypeScript permits:
const invalidState: AsyncState<Project> = {
  isLoading: true,
  data: { id: projectId, ownerId: userId, name: "Website redesign" },
  error: "Network timeout"
};

The Functional Approach: Explicit Sum Types

Use a tagged union where every state variant only contains the fields valid for that state:

export type RemoteData<E, D> =
  | { readonly kind: "idle" }
  | { readonly kind: "loading" }
  | { readonly kind: "success"; readonly data: D }
  | { readonly kind: "failure"; readonly error: E };

// Helper constructors
export const RemoteData = {
  idle: (): RemoteData<never, never> => ({ kind: "idle" }),
  loading: (): RemoteData<never, never> => ({ kind: "loading" }),
  success: <D>(data: D): RemoteData<never, D> => ({ kind: "success", data }),
  failure: <E>(error: E): RemoteData<E, never> => ({ kind: "failure", error }),
};

const projectState: RemoteData<string, Project> =
  RemoteData.success({
    id: projectId,
    ownerId: userId,
    name: "Website redesign",
  });

Part 3: Modeling Real-World Business Rules

Scenario: User Order Lifecycle

Consider an order with these business invariants:

  1. A Draft order has no payment details or tracking number.
  2. A Paid order must have a transactionId and payment timestamp.
  3. A Shipped order must have a transactionId and a trackingCode.
  4. A Cancelled order must have a cancellation reason.
export type OrderId = Brand<string, "OrderId">;
export type TrackingCode = Brand<string, "TrackingCode">;
export type TransactionId = Brand<string, "TransactionId">;

type BaseOrder = {
  readonly id: OrderId;
  readonly projectId: ProjectId;
  readonly ownerId: UserId;
  readonly items: ReadonlyArray<{ sku: string; quantity: number }>;
  readonly totalAmountInCents: number;
};

export type Order =
  | (BaseOrder & { readonly status: "draft" })
  | (BaseOrder & {
      readonly status: "paid";
      readonly transactionId: TransactionId;
      readonly paidAt: Date;
    })
  | (BaseOrder & {
      readonly status: "shipped";
      readonly transactionId: TransactionId;
      readonly trackingCode: TrackingCode;
      readonly shippedAt: Date;
    })
  | (BaseOrder & {
      readonly status: "cancelled";
      readonly reason: string;
      readonly cancelledAt: Date;
    });

Part 4: Safe State Transitions

Transition functions take a specific state as an input and produce the next state. You cannot ship an unpaid draft because the compiler will not accept a draft order in a shipOrder function:

// Only accepts an order that is already paid
export const shipOrder = (
  order: Extract<Order, { status: "paid" }>,
  trackingCode: TrackingCode
): Extract<Order, { status: "shipped" }> => ({
    ...order,
    status: "shipped",
    trackingCode,
    shippedAt: new Date(),
});

Part 5: Exhaustive Pattern Matching

Connecting this back to our previous post on ts-pattern:

import { match } from "ts-pattern";

export const renderOrderStatus = (order: Order): string =>
  match(order)
    .with({ status: "draft" }, () => "Order is being prepared.")
    .with(
      { status: "paid" },
      (paidOrder) => `Paid. Transaction: ${paidOrder.transactionId}`
    )
    .with(
      { status: "shipped" },
      (shippedOrder) => `In transit. Tracking: ${shippedOrder.trackingCode}`
    )
    .with(
      { status: "cancelled" },
      (cancelledOrder) => `Cancelled: ${cancelledOrder.reason}`
    )
    // If a new order status is added, exhaustive() will fail at build time
    .exhaustive();

Key Takeaways

  • Validate at the perimeter: Convert loose raw types (strings, numbers) to branded domain types as early as possible, such as at API endpoints or controller boundaries.
  • Encode invariants in types: Avoid optional flags like isLoading and hasError that permit undefined states.
  • Make transitions explicit: Functions should take the exact prerequisite state type and return the resulting state type.