Understanding and Applying the Strategy Pattern in TypeScript for Extensible Business Logic
Introduction
Modern applications constantly evolve: tax rules change, pricing models are tweaked, and validation requirements shift. Hard‑coding such variations leads to massive if/else blocks, brittle code, and endless merge conflicts.
The Strategy pattern offers a clean solution: encapsulate each algorithm (or business rule) behind a common interface and swap implementations at runtime. When combined with TypeScript’s static typing, generics, and discriminated unions, the pattern becomes a powerful toolbox for building extensible, type‑safe business logic.
In this article we will:
- Explain the intent and structure of the Strategy pattern.
- Show a step‑by‑step implementation in TypeScript, leveraging its type system for compile‑time safety.
- Demonstrate real‑world scenarios (pricing engine, validation pipeline, and a plug‑in‑style discount system).
- Provide guidelines for scaling the pattern in larger codebases.
Note: The focus is on practical application, not on theoretical history. All code snippets are self‑contained and can be dropped into a Node.js or Next.js project.
1. Core concepts of the Strategy pattern
| Element | Description |
|---|---|
| Strategy | An interface that defines a contract for a family of algorithms. |
| ConcreteStrategy | A class that implements the Strategy interface with a specific algorithm. |
| Context | The object that holds a reference to a Strategy and delegates work to it. |
The pattern enables open/closed design: you can add new strategies without touching existing code.
UML (simplified)
+-----------+ uses +------------+
| Context | ----------------> | Strategy |
+-----------+ +------------+
^ ^
| |
+----------------+ +----------------+
| |
+----------------+ +-------------------+
| ConcreteA | | ConcreteB |
+----------------+ +-------------------+
2. Type‑Safe Strategy Interface in TypeScript
We start with a generic interface that captures the shape of any strategy:
// src/strategy.ts
export interface Strategy<Input, Output> {
/** Execute the algorithm */
execute(input: Input): Output;
}
InputandOutputare generic placeholders, allowing each concrete strategy to define its own data shapes while still conforming to a common contract.*
Using discriminated unions for strategy selection
When the client must choose a strategy based on a runtime value (e.g., a type field), a discriminated union gives us exhaustive‑check safety.
export type PricingStrategyType = 'flat' | 'tiered' | 'volume';
export interface StrategyConfig {
type: PricingStrategyType;
// additional config fields can be added later without breaking existing code
}
3. Real‑World Example 1 – A Pricing Engine
Imagine an e‑commerce platform that supports three pricing models:
| Model | Description |
|---|---|
| Flat | Same price per unit (price * qty). |
| Tiered | Different unit price depending on quantity ranges. |
| Volume | Discount applied as a percentage of the subtotal. |
3.1 Define the domain types
// src/domain.ts
export interface OrderLine {
sku: string;
quantity: number;
unitPrice: number; // base price before discounts
}
export interface PricingResult {
lineTotal: number;
discountApplied: number;
}
3.2 Implement concrete strategies
// src/pricing/FlatPricing.ts
import { Strategy } from '../strategy';
import { OrderLine, PricingResult } from '../domain';
export class FlatPricing implements Strategy<OrderLine, PricingResult> {
execute(line: OrderLine): PricingResult {
const lineTotal = line.quantity * line.unitPrice;
return { lineTotal, discountApplied: 0 };
}
}
// src/pricing/TieredPricing.ts
import { Strategy } from '../strategy';
import { OrderLine, PricingResult } from '../domain';
interface Tier {
upTo: number; // inclusive upper bound
price: number; // unit price for this tier
}
/**
* Example tiers:
* - 1‑9 units: $10
* - 10‑49 units: $9
* - 50+ units: $8
*/
export class TieredPricing implements Strategy<OrderLine, PricingResult> {
private readonly tiers: Tier[];
constructor(tiers: Tier[]) {
this.tiers = tiers;
}
execute(line: OrderLine): PricingResult {
const tier = this.tiers.find(t => line.quantity <= t.upTo) ?? this.tiers[this.tiers.length - 1];
const lineTotal = line.quantity * tier.price;
const discountApplied = (line.unitPrice - tier.price) * line.quantity;
return { lineTotal, discountApplied };
}
}
// src/pricing/VolumePricing.ts
import { Strategy } from '../strategy';
import { OrderLine, PricingResult } from '../domain';
export class VolumePricing implements Strategy<OrderLine, PricingResult> {
constructor(private readonly discountRate: number) {} // e.g., 0.15 for 15%
execute(line: OrderLine): PricingResult {
const subtotal = line.quantity * line.unitPrice;
const discount = subtotal * this.discountRate;
const lineTotal = subtotal - discount;
return { lineTotal, discountApplied: discount };
}
}
3.3 Context that selects a strategy
// src/pricing/PricingEngine.ts
import { Strategy, StrategyConfig } from '../strategy';
import { OrderLine, PricingResult } from '../domain';
import { FlatPricing } from './FlatPricing';
import { TieredPricing } from './TieredPricing';
import { VolumePricing } from './VolumePricing';
export class PricingEngine {
private readonly strategies: Record<StrategyConfig['type'], Strategy<OrderLine, PricingResult>>;
constructor(config: { tiers?: TieredPricing['tiers']; volumeDiscount?: number }) {
this.strategies = {
flat: new FlatPricing(),
tiered: new TieredPricing(config.tiers ?? [
{ upTo: 9, price: 10 },
{ upTo: 49, price: 9 },
{ upTo: Infinity, price: 8 },
]),
volume: new VolumePricing(config.volumeDiscount ?? 0.10),
};
}
/** Pick a strategy based on runtime config */
calculate(line: OrderLine, strategyType: StrategyConfig['type']): PricingResult {
const strategy = this.strategies[strategyType];
return strategy.execute(line);
}
}
Usage
import { PricingEngine } from './pricing/PricingEngine';
import { OrderLine } from './domain';
const engine = new PricingEngine({ volumeDiscount: 0.12 });
const line: OrderLine = { sku: 'ABC-123', quantity: 23, unitPrice: 10 };
const flat = engine.calculate(line, 'flat');
const tiered = engine.calculate(line, 'tiered');
const volume = engine.calculate(line, 'volume');
console.log({ flat, tiered, volume });
What we gain
- Adding a new pricing model (e.g., “subscription‑based”) requires only a new class that implements
Strategy<OrderLine, PricingResult>and a tiny entry in thestrategiesmap. No existingif/elseneeds to be touched. - TypeScript guarantees that each strategy’s
executemethod respects the input and output shapes, catching mismatches at compile time.
4. Real‑World Example 2 – Validation Pipeline
A different domain: validating user‑submitted data where each rule can be turned on/off per product line.
4.1 Strategy interface for validators
export interface Validator<T> {
/** Return an error message or `null` if the value passes */
validate(value: T): string | null;
}
4.2 Concrete validators
// src/validation/EmailValidator.ts
import { Validator } from '../strategy';
export class EmailValidator implements Validator<string> {
private readonly re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
validate(value: string) {
return this.re.test(value) ? null : 'Invalid email address';
}
}
// src/validation/MinLengthValidator.ts
import { Validator } from '../strategy';
export class MinLengthValidator implements Validator<string> {
constructor(private readonly min: number) {}
validate(value: string) {
return value.length >= this.min ? null : `Must be at least ${this.min} characters`;
}
}
4.3 Validation context that composes strategies
// src/validation/ValidatorEngine.ts
import { Validator } from '../strategy';
export class ValidatorEngine<T> {
constructor(private readonly validators: Validator<T>[]) {}
/** Run all validators and collect messages */
run(value: T): string[] {
return this.validators
.map(v => v.validate(value))
.filter((msg): msg is string => msg !== null);
}
}
Example usage
import { EmailValidator } from './validation/EmailValidator';
import { MinLengthValidator } from './validation/MinLengthValidator';
import { ValidatorEngine } from './validation/ValidatorEngine';
const emailEngine = new ValidatorEngine<string>([
new EmailValidator(),
new MinLengthValidator(8),
]);
const errors = emailEngine.run('bad@e');
console.log(errors); // ["Invalid email address", "Must be at least 8 characters"]
Benefits
- Validators are completely reusable across forms.
- Adding a new rule is a matter of writing a tiny class that implements
Validator<T>. - The engine can be injected (e.g., via a DI container) to keep controllers thin.
5. Scaling the Strategy pattern in a monorepo
When the number of strategies grows into the dozens, a few organizational tricks keep the codebase navigable:
| Technique | How it helps |
|---|---|
Folder‑by‑concern (src/pricing/strategies/*) |
Keeps related concrete strategies together. |
Index barrel files (export * from './FlatPricing') |
Simplifies imports and enables tree‑shaking. |
Factory functions (createPricingStrategy(type, cfg)) |
Centralises instantiation logic, useful for runtime configuration (feature flags, env vars). |
| Type‑level registry | Using a mapped type to tie a string literal to its concrete class gives compile‑time lookup. |
5.1 Type‑level registry example
// src/pricing/registry.ts
import { FlatPricing } from './FlatPricing';
import { TieredPricing } from './TieredPricing';
import { VolumePricing } from './VolumePricing';
import { Strategy } from '../strategy';
import { OrderLine, PricingResult } from '../domain';
export type PricingStrategyMap = {
flat: typeof FlatPricing;
tiered: typeof TieredPricing;
volume: typeof VolumePricing;
};
export type PricingStrategyInstance<K extends keyof PricingStrategyMap> =
InstanceType<PricingStrategyMap[K]>;
export function createPricingStrategy<K extends keyof PricingStrategyMap>(
type: K,
...args: ConstructorParameters<PricingStrategyMap[K]>
): PricingStrategyInstance<K> {
const ctor = {
flat: FlatPricing,
tiered: TieredPricing,
volume: VolumePricing,
}[type] as any;
return new ctor(...args);
}
Now the caller gets exact typing for the returned instance:
const tiered = createPricingStrategy('tiered', [
{ upTo: 9, price: 10 },
{ upTo: 49, price: 9 },
{ upTo: Infinity, price: 8 },
]);
// `tiered` is inferred as TieredPricing, so its public API is fully typed.
6. Best Practices & Gotchas
- Prefer composition over inheritance – the Strategy pattern already gives you polymorphism; adding a subclass hierarchy rarely adds value.
- Keep the Strategy interface minimal – only the method(s) required for the algorithm. Extra responsibilities belong in separate interfaces.
- Don’t over‑engineer – if you have only two simple branches, an
ifis fine. The pattern shines when you anticipate growth. - Leverage discriminated unions for configuration – they force exhaustive
switchstatements, preventing forgotten cases. - Document the intent – add JSDoc comments explaining why a particular strategy exists; future developers will understand the business rule behind each class.
- Testing – each concrete strategy can be unit‑tested in isolation. The context (e.g.,
PricingEngine) can be tested with mock strategies to verify delegation logic.
7. Summary
The Strategy pattern, when paired with TypeScript’s generics and discriminated unions, provides a clean, scalable way to model mutable business rules. By:
- Defining a generic
Strategy<Input, Output>contract, - Implementing concrete classes for each algorithm,
- Using a context or factory to select the appropriate strategy at runtime,
you achieve a codebase that is open for extension, closed for modification, and fully type‑checked at compile time.
Whether you’re building a pricing engine, a validation pipeline, or any domain where rules change frequently, the pattern keeps your core logic simple and your extensions painless.
Happy coding! 🚀
Member discussion