- Book: The TypeScript Type System — From Generics to DSL-Level Types
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a TypeScript file written by someone whose last language was Java. The payment module has the full kit: a PaymentStrategy interface, five concrete classes that implement charge(), a PaymentStrategyFactory that maps a string key to a class, and a registry the factory queries. The whole thing is around 200 lines. Adding a sixth provider means touching the interface, writing the class, registering it in the factory, and praying nobody forgot the case in the switch that lives inside the factory itself.
The author is not a bad engineer. They are doing what the Gang of Four book told them to do in 1994. Strategy is one of the OG behavioural patterns: define a family of algorithms, encapsulate each, make them interchangeable. In Java and Kotlin and C# the encapsulation unit is a class because that is the only encapsulation unit those languages give you. The pattern is the workaround for the missing language feature.
TypeScript has the missing feature. It is called a discriminated union. The strategy pattern in TypeScript is a single type and a single dispatch function, and the compiler will tell you the day you forget a variant. The 200-line OOP version becomes 40 lines that read cleaner and the compiler catches the case you would have forgotten.
The Textbook Version (Why Your Codebase Has 200 Lines for This)
Here is the strategy pattern as the GoF book describes it, transcribed into TypeScript so the comparison is fair. Charge a card with one of several providers.
interface PaymentStrategy {
charge(amountCents: number): Promise<ChargeResult>;
}
type ChargeResult =
| { ok: true; reference: string }
| { ok: false; error: string };
// Concrete strategies — one class per provider
class StripeStrategy implements PaymentStrategy {
constructor(private readonly apiKey: string) {}
async charge(amountCents: number): Promise<ChargeResult> {
const ref = await callStripe(this.apiKey, amountCents);
return { ok: true, reference: ref };
}
}
class PayPalStrategy implements PaymentStrategy {
constructor(
private readonly clientId: string,
private readonly secret: string,
) {}
async charge(amountCents: number): Promise<ChargeResult> {
const ref = await callPayPal(this.clientId, this.secret, amountCents);
return { ok: true, reference: ref };
}
}
class AdyenStrategy implements PaymentStrategy { /* ... */ }
class MollieStrategy implements PaymentStrategy { /* ... */ }
class BankTransferStrategy implements PaymentStrategy { /* ... */ }
That is the surface. The factory is the part the GoF book tells you to write to keep the call sites clean.
type ProviderKey = "stripe" | "paypal" | "adyen" | "mollie" | "bank";
class PaymentStrategyFactory {
private readonly registry = new Map<ProviderKey, PaymentStrategy>();
register(key: ProviderKey, strategy: PaymentStrategy): void {
this.registry.set(key, strategy);
}
get(key: ProviderKey): PaymentStrategy {
const s = this.registry.get(key);
if (!s) throw new Error(`unknown provider: ${key}`);
return s;
}
}
const factory = new PaymentStrategyFactory();
factory.register("stripe", new StripeStrategy(env.STRIPE_KEY));
factory.register("paypal", new PayPalStrategy(env.PAYPAL_ID, env.PAYPAL_SECRET));
factory.register("adyen", new AdyenStrategy(env.ADYEN_KEY));
factory.register("mollie", new MollieStrategy(env.MOLLIE_KEY));
factory.register("bank", new BankTransferStrategy(env.BANK_IBAN));
// Call site
async function chargeOrder(provider: ProviderKey, cents: number) {
return factory.get(provider).charge(cents);
}
Count what is happening. Five classes. One interface. One factory. One registry. One enum-shaped key type. The constructor of every class accepts a different shape of credentials.
The factory's register call is not type-aware: passing a StripeStrategy to the "paypal" key compiles, because both satisfy PaymentStrategy. The runtime will discover the mismatch when somebody charges through the wrong key.
Adding a sixth provider means a new class, a new register call, and updating ProviderKey. Nothing in the type system forces the three changes to stay in sync. The if (!s) throw line in factory.get exists because the registry can be incomplete and the type system cannot tell.
That is the cost of doing strategy as classes. The pattern is solving the right problem with the wrong tool.
The TypeScript Rewrite
Same job, different shape. Define one type whose variants encode the provider and its credentials together, then write one function that dispatches on the discriminant.
type Payment =
| { kind: "stripe"; apiKey: string; cents: number }
| { kind: "paypal"; clientId: string; secret: string; cents: number }
| { kind: "adyen"; apiKey: string; cents: number }
| { kind: "mollie"; apiKey: string; cents: number }
| { kind: "bank"; iban: string; cents: number };
type ChargeResult =
| { ok: true; reference: string }
| { ok: false; error: string };
The discriminant is kind. Every variant carries the credentials that variant needs and nothing else. There is no abstract PaymentStrategy because there is nothing useful to abstract. The providers do not share fields and they do not share behaviour. The interface in the OOP version was a fiction.
The dispatcher is a single function with a switch.
async function dispatch(p: Payment): Promise<ChargeResult> {
switch (p.kind) {
case "stripe":
return charge(await callStripe(p.apiKey, p.cents));
case "paypal":
return charge(await callPayPal(p.clientId, p.secret, p.cents));
case "adyen":
return charge(await callAdyen(p.apiKey, p.cents));
case "mollie":
return charge(await callMollie(p.apiKey, p.cents));
case "bank":
return charge(await callBank(p.iban, p.cents));
default:
return assertNever(p);
}
}
function charge(reference: string): ChargeResult {
return { ok: true, reference };
}
Inside case "stripe":, the checker narrows p to the stripe variant. p.apiKey exists. p.clientId does not — accessing it is a type error. The same narrowing happens in every case. You cannot reach for a field a variant does not have, because at the moment you reach, the variant is the only thing in scope.
The assertNever helper at the bottom is where the second half of the win lives.
function assertNever(value: never, message?: string): never {
throw new Error(
message ?? `unhandled payment kind: ${JSON.stringify(value)}`,
);
}
The trick: a parameter typed never only accepts a value the compiler has already proved cannot exist.
assertNever takes a never, which is the bottom type. No value inhabits it. So the call assertNever(p) only type-checks if the checker has already narrowed p down to never by the time control reaches the default branch. That narrowing only happens when every concrete variant has been handled by an earlier case. Miss one, and p is still that variant at the bottom of the switch, the call fails to type-check, and the build breaks. That is what makes the dispatcher exhaustive.
Add a sixth provider, Apple Pay, by appending one line to the union:
| { kind: "applepay"; merchantId: string; token: string; cents: number };
The dispatcher file goes red without you touching it. tsc prints something close to:
src/dispatch.ts(14,28): error TS2345: Argument of type
'{ kind: "applepay"; merchantId: string; token: string; cents: number; }'
is not assignable to parameter of type 'never'.
Every site that dispatches on Payment lights up with the same error. There is nowhere to hide. The type system has turned "did we add the case everywhere we needed to" from a code review concern into a compiler error. The feeling is the same one Rust developers get from match arms and Haskell developers get from incomplete patterns: the language refuses to let you ship the bug.
That is the whole strategy pattern, replaced. One type, one function, one helper. Roughly 40 lines including blank ones. No factory. No registry. No constructor-injection plumbing. The call site reads as dispatch(p) and the credentials travel inside the value, which means the wire format and the in-memory shape are the same shape. Value in, result out.
When Dispatch Is Needed in Many Places: the Record Registry
The switch works for one dispatcher. Real systems have several: one for charging, one for refunding, one for emitting an analytics event per payment kind, one for serialising the payment to the audit log. Repeating the switch five times is fine. There is also a typed-record pattern that some teams reach for, and it is worth knowing because it has different tradeoffs.
type Kind = Payment["kind"];
// Helper: extract the variant where K is the discriminant value
type ByKind<K extends Kind> = Extract<Payment, { kind: K }>;
type Handlers<R> = {
[K in Kind]: (p: ByKind<K>) => R;
};
const charge: Handlers<Promise<ChargeResult>> = {
stripe: (p) => callStripe(p.apiKey, p.cents).then(refToResult),
paypal: (p) => callPayPal(p.clientId, p.secret, p.cents).then(refToResult),
adyen: (p) => callAdyen(p.apiKey, p.cents).then(refToResult),
mollie: (p) => callMollie(p.apiKey, p.cents).then(refToResult),
bank: (p) => callBank(p.iban, p.cents).then(refToResult),
};
function dispatch(p: Payment): Promise<ChargeResult> {
// The cast is safe: the mapped type guarantees the handler matches the kind.
return (charge[p.kind] as (p: Payment) => Promise<ChargeResult>)(p);
}
The Handlers<R> mapped type is the trick. For each K in Kind, the handler at key K accepts only the variant whose kind is K. Inside charge.stripe, p is the stripe variant. Inside charge.paypal, p is the paypal variant. The keys of the record are constrained to the keys of the union, which means leaving one out is a type error: TypeScript will tell you Property 'bank' is missing in type the moment the union grows.
The cast inside dispatch is the one wart. The mapped type is correct but TypeScript doesn't always express the connection between the runtime key and the looked-up handler without help. The cast is sound: every handler accepts its own variant, and p.kind is what indexes into charge. The checker just doesn't infer that. You write the cast once and never again.
The registry pattern wins when:
- Three or more dispatch sites do the same kind-by-kind work and the
switchwould repeat. - You want the handlers as data so you can compose them — middleware, instrumentation wrappers, a "test stub" registry that swaps implementations per test.
- You want to register handlers across module boundaries without a factory class.
The switch wins when there is one dispatcher and the cases want to early-return, throw, or fall through. The two patterns coexist in the same file. Reach for the registry when repetition forces it; otherwise the switch reads cleaner.
Mat Ryer's Go talks make the same point in a different language: when behaviour is a function of data, store the function and the data together rather than wrap them in an object hierarchy. The Go community has been shipping the same shift for years, replacing the strategy-pattern class hierarchy with a function table indexed by an enum. The language does not change the structural answer.
When Subclasses Still Win
The discriminated-union answer is right for most strategy use cases. It is not right for all of them. The OOP strategy pattern still earns its keep in three places.
Heavy state per implementation. When each strategy carries its own state machine (open connections, retry counters, a token cache, a worker pool), the class form gives you a place to put it. You can write that state into a union variant, but the variant becomes a giant bag of fields and the dispatcher has to thread the state in and out on every call. A class with private state is a cleaner home for a Stripe client that maintains a connection pool.
Plugin-style runtime extension. When third-party packages register strategies into your application at runtime, you cannot put the extension in your union. A CMS that loads payment integrations from an npm install, or an IDE that loads syntax highlighters as plugins, are the cases. Your code does not know about them at compile time. The closed-set property that makes discriminated unions safe is what makes them wrong for the open-extension case. A registry of PaymentStrategy instances is the right shape there.
Lots of shared mixin behaviour. When every strategy shares ten methods (logCharge, metricsTag, auditTrail, validateAmount) and only differs in two, an abstract base class with a template method is genuinely less duplication than a union where every dispatcher has to factor out the shared behaviour. This is rarer than people think (most "shared" methods turn out to be free functions in disguise). When it is real, classes carry their weight.
If your codebase uses strategy for any of those three reasons, leave it alone. If it uses strategy because the engineer's last language was Java and that was the only way they knew to dispatch on a value, the discriminated-union rewrite is sitting there waiting.
The next time you reach for an interface with two methods and three implementing classes, stop. Ask whether the difference between the implementations is data or behaviour. If it is data (different fields, different credentials, different inputs), write a discriminated union and a dispatch function. The compiler catches the case you would have forgotten, the call site reads as dispatch(p), and the next person to add a provider edits one type and one function instead of five files.
If this was useful
The strategy-as-discriminated-union shape is one slice of what The TypeScript Type System is about: using the type system to encode the closed sets, narrowing rules, and exhaustiveness checks that other languages either lack or charge you a class hierarchy to express. The book builds from literal types and narrowing up through never, satisfies, mapped types, conditional types, and the Extract/infer machinery the registry pattern in this post depends on. If you want to know why case "stripe": narrows the way it does and how to write the helpers Handlers<R> and ByKind<K> for your own unions, that is the book.
If you are coming from JVM languages, the move from sealed-class hierarchies to discriminated unions is exactly the bridge Kotlin and Java to TypeScript is built around. If you are coming from PHP 8+, PHP to TypeScript covers discriminated unions from the side of someone who has been writing match expressions and abstract classes. If you are shipping TS at work, TypeScript in Production covers the build, monorepo, and library-authoring concerns the type system itself does not touch.
The five-book set:
- TypeScript Essentials — From Working Developer to Confident TS, Across Node, Bun, Deno, and the Browser — entry point: amazon.com/dp/B0GZB7QRW3
- The TypeScript Type System — From Generics to DSL-Level Types — deep dive: amazon.com/dp/B0GZB86QYW
- Kotlin and Java to TypeScript — A Bridge for JVM Developers — bridge for JVM devs: amazon.com/dp/B0GZB2333H
- PHP to TypeScript — A Bridge for Modern PHP 8+ Developers — bridge for PHP devs: amazon.com/dp/B0GZBD5HMF
- TypeScript in Production — Tooling, Build, and Library Authoring Across Runtimes — production layer: amazon.com/dp/B0GZB7F471
All five books ship in ebook, paperback, and hardcover.

Top comments (0)