Factory, Strategy, Observer, and Builder with real type guarantees
TypeScript turns time-tested GoF patterns from object-oriented software design into tools with real compile-time guarantees. Combining Factory, Strategy, Observer, and Builder with discriminated unions, generic types, and exhaustiveness checks prevents entire classes of bugs that only surface at runtime in plain JavaScript, while producing more maintainable, better-tested architectures for checkout and headless commerce systems.
Table of Contents
- 1. Why classic GoF patterns benefit from TypeScript
- 2. Discriminated unions and exhaustiveness checks as the foundation
- 3. The Strategy pattern: interchangeable algorithms, type-safely
- 4. The Factory pattern: type-safe object creation without any
- 5. The Observer pattern: typed events instead of any payloads
- 6. The Builder pattern: fluent APIs with type-safe chaining
- 7. Avoiding pattern overuse: when a simple function is enough
- 8. Practical example: PaymentStrategyFactory in checkout
- 9. Design patterns compared: when is the effort worth it?
- 10. Summary
- 11. FAQ
1. Why classic GoF patterns benefit from TypeScript
The classic design patterns from the "Gang of Four" book were designed for statically typed languages like C++ and Smalltalk. Their core mechanism is polymorphism through interfaces and abstract classes, enforced by the compiler. In plain JavaScript, that enforcement simply does not exist: duck typing means a missing method on a "strategy" implementation or a mismatched field in an event payload only surfaces at runtime, often in front of a real user in production rather than in code review.
TypeScript restores exactly the discipline these patterns were originally designed around. The compiler enforces interface contracts, flags missing methods immediately, checks parameter and return types, and makes forgotten cases in a switch statement visible before the code is ever built. The type system becomes executable documentation in the process: whoever adds a new strategy or a new observer handler sees directly in the IDE which methods and which payload shape the contract requires, without consulting external documentation or tests.
2. Discriminated unions and exhaustiveness checks as the foundation
Almost every type-safe pattern implementation in TypeScript relies on the same core technique: the discriminated union. Every member of the union gets a shared literal field, usually type or kind, which the compiler uses to automatically narrow the concrete type inside a switch or if. Inside a case 'paypal': block, TypeScript then knows exactly which fields exist only for that union member, with no manual type casting or runtime checks required.
The second building block is the exhaustiveness check: in the default branch of a switch, the remaining value gets assigned to a variable typed never. As long as every case is handled, this compiles cleanly. The moment someone later adds a new union member, say another payment method, without handling the corresponding case, that exact line fails to compile. This pattern runs through Factory, Strategy, and Observer alike, and it is the actual reason these patterns are more robust in TypeScript than their JavaScript equivalent.
3. The Strategy pattern: interchangeable algorithms, type-safely
The Strategy pattern encapsulates interchangeable algorithms behind a shared interface instead of scattering them across the business logic as a long if/else chain. For a checkout with several payment methods, that means every payment method gets its own class implementing the same PaymentStrategy interface, with an identical signature for fee calculation and payment processing.
The advantage over the JavaScript version shows up when extending the contract: adding a new required method to the interface, say refund(), immediately marks every existing strategy class as incomplete, right where it is defined, not only wherever it eventually gets called. Calling code never needs to know the concrete implementation; it works exclusively against the interface and always receives the same, fully typed return structure.
// Strategy contract: every payment strategy must implement this interface
interface PaymentStrategy {
readonly label: string;
calculateFee(amountCents: number): number;
process(amountCents: number): Promise<{ success: boolean; transactionId: string }>;
}
// Concrete strategy: credit card payments with a percentage-based fee
class CreditCardStrategy implements PaymentStrategy {
readonly label = 'Credit Card';
calculateFee(amountCents: number): number {
return Math.round(amountCents * 0.019 + 30);
}
async process(amountCents: number): Promise<{ success: boolean; transactionId: string }> {
// A real integration would call a payment service provider SDK here
return { success: true, transactionId: `cc_${Date.now()}` };
}
}
// Concrete strategy: PayPal payments with a different fee formula
class PayPalStrategy implements PaymentStrategy {
readonly label = 'PayPal';
calculateFee(amountCents: number): number {
return Math.round(amountCents * 0.029 + 39);
}
async process(amountCents: number): Promise<{ success: boolean; transactionId: string }> {
return { success: true, transactionId: `pp_${Date.now()}` };
}
}
// Concrete strategy: invoice payments, no processing fee at all
class InvoiceStrategy implements PaymentStrategy {
readonly label = 'Invoice';
calculateFee(): number {
return 0;
}
async process(amountCents: number): Promise<{ success: boolean; transactionId: string }> {
return { success: true, transactionId: `inv_${Date.now()}` };
}
}
4. The Factory pattern: type-safe object creation without any
The Factory pattern centralizes the decision of which concrete class to instantiate for a given input, fully decoupling calling code from the concrete classes. Instead of every checkout component needing to know how to construct a PayPalStrategy, it asks a factory function for the right strategy given a payment method.
In TypeScript, the payment method is modeled as a string literal union, for example 'credit-card' | 'paypal' | 'invoice', and the factory function checks this value via switch. The decisive difference from the JavaScript version: the default branch assigns the remaining value to a variable typed never. If the union is later extended with a fourth payment method without adding the matching case, the build fails immediately with a clear compiler error, instead of the new payment method silently falling through in production and never receiving a strategy.
// Discriminated union: every supported payment method as a string literal type
type PaymentMethod = 'credit-card' | 'paypal' | 'invoice';
// Typed factory function: the return type is PaymentStrategy on every branch
function createPaymentStrategy(method: PaymentMethod): PaymentStrategy {
switch (method) {
case 'credit-card':
return new CreditCardStrategy();
case 'paypal':
return new PayPalStrategy();
case 'invoice':
return new InvoiceStrategy();
default:
// Exhaustiveness check: fails to compile if a PaymentMethod case is missing
const exhaustiveCheck: never = method;
throw new Error(`Unhandled payment method: ${exhaustiveCheck}`);
}
}
// Usage inside a checkout service, fully decoupled from concrete strategy classes
async function chargeOrder(method: PaymentMethod, amountCents: number) {
const strategy = createPaymentStrategy(method);
const fee = strategy.calculateFee(amountCents);
return strategy.process(amountCents + fee);
}
5. The Observer pattern: typed events instead of any payloads
The Observer pattern decouples event sources from their subscribers, for instance a shopping cart that reports changes from a UI component that reacts to them. The classic problem in JavaScript, even with Node's built-in EventEmitter: an event's payload is typically any, so typos in the event name or mismatched fields in the payload only surface at runtime, often far away from the actual mistake.
With a discriminated union per event type and a generic emitter, this is fully resolved. Every event type, such as item-added or checkout-started, gets its own payload shape. A listener registered for item-added automatically receives only the matching fields like sku and quantity, with no manual casting required. Typos in event names and mistyped payloads become compile-time errors instead of silent runtime bugs that only surface later in the bug tracker.
// Discriminated union describing every cart event and its payload shape
type CartEvent =
| { type: 'item-added'; sku: string; quantity: number }
| { type: 'item-removed'; sku: string }
| { type: 'checkout-started'; totalCents: number };
type Listener<E extends CartEvent> = (event: E) => void;
// Generic, typed event emitter: no "any" payloads, subscribers get narrowed types
class TypedCartEmitter {
private listeners: { [K in CartEvent['type']]?: Listener<Extract<CartEvent, { type: K }>>[] } = {};
on<K extends CartEvent['type']>(type: K, listener: Listener<Extract<CartEvent, { type: K }>>): void {
const bucket = this.listeners[type] ?? [];
bucket.push(listener as never);
this.listeners[type] = bucket as never;
}
emit(event: CartEvent): void {
const bucket = this.listeners[event.type];
bucket?.forEach((listener) => listener(event as never));
}
}
// Consumer code receives a fully typed payload, no manual casting required
const emitter = new TypedCartEmitter();
emitter.on('item-added', (event) => {
console.log(`Added ${event.quantity}x ${event.sku}`);
});
6. The Builder pattern: fluent APIs with type-safe chaining
The Builder pattern constructs complex objects step by step, instead of offering a constructor with eight or more parameters whose order is nearly impossible to remember. It is especially useful when an object has many optional fields or needs validation before it is fully constructed, for example an order with required fields like customer and shipping address alongside optional fields like a note.
In TypeScript, every setter method returns this with the correct type, so chaining stays fully type-safe and the IDE correctly suggests the available next methods after every call. The final build() method checks required fields at runtime and returns a readonly-typed result object that is guaranteed to be complete. For objects with only two or three required fields, though, the overhead rarely pays off, which the next section covers in more detail.
interface Order {
readonly customerId: string;
readonly items: ReadonlyArray<{ sku: string; quantity: number }>;
readonly shippingAddress: string;
readonly note?: string;
}
// Fluent builder: every method returns "this", enabling typed method chaining
class OrderBuilder {
private customerId?: string;
private items: { sku: string; quantity: number }[] = [];
private shippingAddress?: string;
private note?: string;
forCustomer(customerId: string): this {
this.customerId = customerId;
return this;
}
addItem(sku: string, quantity: number): this {
this.items.push({ sku, quantity });
return this;
}
shipTo(address: string): this {
this.shippingAddress = address;
return this;
}
withNote(note: string): this {
this.note = note;
return this;
}
build(): Order {
if (!this.customerId || !this.shippingAddress) {
throw new Error('customerId and shippingAddress are required');
}
return {
customerId: this.customerId,
items: this.items,
shippingAddress: this.shippingAddress,
note: this.note,
};
}
}
// Usage: readable, chainable, and the final object is fully typed and readonly
const order = new OrderBuilder()
.forCustomer('cust_42')
.addItem('SKU-1', 2)
.shipTo('123 Main Street, Springfield')
.build();
7. Avoiding pattern overuse: when a simple function is enough
Not every piece of business logic needs a formal design pattern. An interface, a concrete implementation, and a wrapper class for an algorithm that will never be swapped out only produce extra files, extra indirection, and extra cognitive load for whoever reads the code later, without any real benefit to show for it. TypeScript makes patterns safer, but it does not make a poorly chosen pattern free.
A typical example: a TaxStrategy with exactly one implementation for German VAT, wrapped in a TaxCalculator class that internally just delegates to the strategy. As long as there is no second, currently needed tax rate, a single typed function does exactly the same job, with no class hierarchy and no dependency injection. The rule of thumb: a pattern pays off once at least two real, currently needed variants exist, not preemptively for a hypothetical extension that may never happen.
// Over-engineered: a full Strategy hierarchy for one fixed, never-changing algorithm
interface TaxStrategy {
calculate(amount: number): number;
}
class GermanVatStrategy implements TaxStrategy {
calculate(amount: number): number {
return Math.round(amount * 0.19 * 100) / 100;
}
}
class TaxCalculator {
constructor(private readonly strategy: TaxStrategy) {}
calculate(amount: number): number {
return this.strategy.calculate(amount);
}
}
const calculator = new TaxCalculator(new GermanVatStrategy());
calculator.calculate(100);
// Equivalent, simpler: a plain typed function does the same job with no ceremony
function calculateGermanVat(amount: number): number {
return Math.round(amount * 0.19 * 100) / 100;
}
calculateGermanVat(100);
8. Practical example: PaymentStrategyFactory in checkout
Combining sections three and four is where the practical value becomes most obvious: in a headless checkout, the customer picks a payment method in the frontend, the backend calls createPaymentStrategy(method), and gets back a fully typed PaymentStrategy instance. The checkout service itself never knows about CreditCardStrategy, PayPalStrategy, or InvoiceStrategy as concrete classes; it works exclusively against the interface and stays independent of how many payment methods are supported or how they work internally.
The practical payoff shows up most clearly in testing and extending the system: each strategy can be unit-tested in isolation without mocking the factory or other strategies. Adding a new payment method like Klarna just means a new class plus a new case in the factory, existing strategies stay untouched, in the spirit of the Open/Closed principle. And the moment the PaymentMethod union grows to include that new method, the compiler flags every place still missing a case, essentially a free regression test on every tsc run.
9. Design patterns compared: when is the effort worth it?
Every pattern covered here has a scenario where it is the right choice, and a scenario where it is pure ceremony with no upside. The table below summarizes when a pattern is appropriate and when a simpler solution is enough.
| Scenario | Not recommended (overkill) | Recommended |
|---|---|---|
| Single, fixed algorithm | Strategy pattern with an interface | Simple typed function |
| Multiple interchangeable algorithms | if/else chain in business code | Strategy pattern |
| Simple object creation with no conditions | Factory class with a registry | Direct constructor call |
| Complex, conditional object creation | Object literal with nested conditions | Factory pattern with exhaustiveness check |
| One-off object construction, few fields | Builder class with a fluent API | Object literal or constructor |
| Many optional constructor parameters | Constructor with eight or more parameters | Builder pattern |
| Static list of fixed listeners | Full observer framework | Direct function call |
| Dynamic pub/sub with many subscribers | Manual array of untyped callbacks | Observer pattern with a typed event emitter |
The table reveals a recurring pattern: the decision for or against a design pattern should follow the actual variability present in the requirements, not a preemptively anticipated flexibility that may never be needed. TypeScript makes applying a pattern correctly cheaper and safer, but it does not make the wrong choice of pattern free.
Mironsoft
TypeScript architecture, design patterns, and headless commerce integrations for Magento stores
Need a type-safe architecture for your TypeScript project?
We build type-safe frontend and backend building blocks for Magento and headless commerce projects, from Factory and Strategy implementations to fully typed event systems that surface bugs at compile time instead of in front of a customer.
TypeScript architecture review
Analysis of existing pattern usage, prioritized by maintainability
Design pattern refactoring
Retrofitting Factory, Strategy, and Observer implementations to be type-safe
Headless commerce integration
Typed checkout and payment building blocks for Magento and frontend frameworks
10. Summary
Classic GoF patterns like Factory, Strategy, Observer, and Builder are not reinvented in TypeScript, they simply get back the static discipline they were originally designed for. Discriminated unions and exhaustiveness checks via never are the shared foundation: they ensure that missing cases in a factory, incomplete strategy implementations, or mistyped event payloads are caught at compile time, not by a customer in production.
Just as important as implementing a pattern correctly is deciding consciously whether it is needed at all. A single, fixed algorithm does not benefit from a strategy hierarchy, and an object with two required fields does not benefit from a builder. Only once real variability exists, such as multiple payment methods in a checkout, does the extra structure actually pay off in terms of testability, extensibility, and compiler support.
Design Patterns in TypeScript, the Essentials at a Glance
Discriminated unions
Type-safe foundation for Factory, Strategy, and Observer, with an exhaustiveness check via never.
Factory pattern
Object creation without any, compiler errors instead of silent runtime surprises for missing cases.
Strategy & Observer
Interchangeable algorithms and typed events instead of any payloads and long if/else chains.
Avoiding pattern overuse
A pattern pays off once at least two real variants exist, otherwise a simple function is enough.