// Option A: classes
interface PaymentMethod { fee(amount: number): number }
class Card implements PaymentMethod { fee(a: number) { return a * 0.029; } }
// Option B: discriminated union
type Payment = { kind: "card"; last4: string } | { kind: "bank"; iban: string };
function fee(p: Payment, a: number): number {
switch (p.kind) { case "card": return a * 0.029; case "bank": return 0.5; }
}
Ask: what changes more often, the kinds or the operations?
A discriminated union is a union type where every member has a shared tag field, like kind. TypeScript narrows the type inside each case.
Classes behind an interface (Option A) are best when you add new kinds often. A new provider is one new class, and existing code doesn't change. They also fit when each kind has its own dependencies (an SDK client, config) or state.
A union with switch (Option B) is best when the kinds are a fixed set and you add new operations often: fee, label, refundRules. Each operation is one function. Other good points:
- The data is plain, so it works with JSON, databases and queues with no conversion.
- With an exhaustive check, TypeScript shows every
switchyou must update when a new kind is added.
The trade-off between these two is called the expression problem.
In backends: classes for adapters to outside systems (payment providers, storage). Unions for domain states and messages (order status, events).
- Classes make it easy to add new kinds without editing existing code.
- Unions with
switchmake it easy to add new operations over a fixed set of kinds. - Union values are plain data, so they serialize without conversion.
- An exhaustive check makes the compiler list every
switchto update. - Use classes for external adapters and unions for domain states and messages.