Suggestions
← TIL
~2 min read
#typescript#algebraic-data-types#fintech#type-safety

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#

Refactor
ts
// ❌ 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.
// ✅ 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;
};

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#

handle-payment.ts
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;
  }
}

ADTs vs. Alternatives#

ADTs vs. The Rest

Discriminated Unions (ADTs)

  • 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

  • Enums: don't link data to state; everything stays optional
  • Classes: runtime overhead + fragile instanceof checks
  • Enums + interfaces: you duplicate the source of truth
  • False sense of security if using type-casting to 'fix' narrowing

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.

If you want to complement this with runtime validation, check how to use .superRefine() in Zod 4 for cross-field validation. To see how Astro 6 validates collections with these principles, start with the Zod 4 migration in Content Layer.

Link copied to clipboard