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.// ✅ 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#
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.