DEV Community

Cover image for Algebraic Data Types in TS: Indestructible Payment Flows
Hugo Campañoli
Hugo Campañoli

Posted on • Originally published at campa.dev

Algebraic Data Types in TS: Indestructible Payment Flows

Your payment flow has a status: string field? You're one typo away from a double charge. A silent "pednign" in production won't throw an error; it loses money.

Algebraic Data Types (ADTs) in TypeScript let you model states where invalid data is compiler-illegal. Not just "unlikely." Forbidden.

The problem: The bag of optionals

// ❌ Everything is possible... including double charging
interface PaymentState {
  status: string;
  transactionId?: string;
  errorMessage?: string;
  receiptUrl?: string;
  retryable?: boolean;
}

// When does transactionId exist?
// Is it safe to read receiptUrl?
// Nobody knows. Runtime will tell.
Enter fullscreen mode Exit fullscreen mode

When you use a simple string for status, you end up with an interface where everything is optional. The compiler cannot help you because it doesn't know the relationship between the status and the data.

The solution: Discriminated Unions

// ✅ The compiler forbids illegal states
type PaymentState =
  | { status: "idle" }
  | { status: "pending"; intentId: string }
  | {
      status: "processing";
      intentId: string;
      gateway: "bancard" | "stripe";
    }
  | {
      status: "success";
      transactionId: string;
      receiptUrl: string;
    }
  | {
      status: "failed";
      errorCode: number;
      errorMessage: string;
      retryable: boolean;
    };
Enter fullscreen mode Exit fullscreen mode

Each union variant carries only relevant data. No transactionId in "pending"; no errorMessage in "success". The compiler guarantees integrity.

The Guard: Exhaustive switch with never

function handlePayment(state: PaymentState): string {
  switch (state.status) {
    case "idle":
      return "Waiting for user action";
    case "pending":
      return `Intent created: ${state.intentId}`;
    case "processing":
      return `Processing via ${state.gateway}...`;
    case "success":
      return `✅ TX: ${state.transactionId}`;
    case "failed":
      return state.retryable
        ? `Error ${state.errorCode}: retrying`
        : `Fatal error: ${state.errorMessage}`;
    default:
      // Added a new state and forgot to handle it?
      // TypeScript yells here ↓
      const _exhaustive: never = state;
      return _exhaustive;
  }
}
Enter fullscreen mode Exit fullscreen mode

Without that never, any new state added to the union passes silently through the switch. In a payment flow, "silent" means "duplicate charges at 3AM without logs."

ADTs vs. Alternatives

Discriminated Unions (ADTs)

  • Pros:
    • State-specific data (no loose optionals)
    • Automatic compiler narrowing in each case
    • Native exhaustiveness checks with never
    • Zero runtime overhead (erased during compilation)

String Enums / Class Hierarchy

  • Cons:
    • Enums: don't link data to state; everything stays optional
    • Classes: runtime overhead + fragile instanceof checks
    • Enums + interfaces: you duplicate the source of truth

Why it works (The Aha! Moment)

An ADT combines finite states with specific data variants. The compiler is your first QA. If you forget a case, it yells before the code ever hits staging. In payments, "unlikely" isn't enough; you need it to be illegal to represent.

A string is a promise. A union is a contract.


Originally published at campa.dev

Top comments (0)