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.
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;
};
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;
}
}
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)