6 min read

Understanding and Applying the Strategy Pattern in TypeScript for Extensible Business Logic

Learn how to use the Strategy pattern with TypeScript’s type system to build flexible, maintainable business rules that evolve without code churn.
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:

  1. Explain the intent and structure of the Strategy pattern.
  2. Show a step‑by‑step implementation in TypeScript, leveraging its type system for compile‑time safety.
  3. Demonstrate real‑world scenarios (pricing engine, validation pipeline, and a plug‑in‑style discount system).
  4. 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;
}
  • Input and Output are 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 the strategies map. No existing if/else needs to be touched.
  • TypeScript guarantees that each strategy’s execute method 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

  1. Prefer composition over inheritance – the Strategy pattern already gives you polymorphism; adding a subclass hierarchy rarely adds value.
  2. Keep the Strategy interface minimal – only the method(s) required for the algorithm. Extra responsibilities belong in separate interfaces.
  3. Don’t over‑engineer – if you have only two simple branches, an if is fine. The pattern shines when you anticipate growth.
  4. Leverage discriminated unions for configuration – they force exhaustive switch statements, preventing forgotten cases.
  5. Document the intent – add JSDoc comments explaining why a particular strategy exists; future developers will understand the business rule behind each class.
  6. 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! 🚀