jsguides

Strategy Pattern in JavaScript: Swap Algorithms at Runtime

What is the strategy pattern?

The Strategy Pattern is a behavioral design pattern that lets you define a family of algorithms, encapsulate each one as an object, and make them interchangeable. The client code picks which strategy to use at runtime, without knowing anything about the concrete implementation.

The classic problem this solves: a class that needs to behave differently depending on some condition, but the behavior itself keeps changing or growing. Without the pattern, you end up with sprawling if/else chains that get harder to maintain with every new algorithm you add.

With the Strategy Pattern, you extract each algorithm into its own object. The context object holds a reference to whatever strategy is currently active and delegates the work to it. Adding a new algorithm means creating a new strategy object. No touching the context.

Key Components

Context holds a reference to the active strategy and delegates work to it. It doesn’t know or care which concrete algorithm is running.

Strategy Interface defines what every concrete strategy must implement: typically a single method (or set of methods) that the context calls. In JavaScript, you don’t need a formal interface; duck typing handles this for you. Any object with the expected method works as a strategy.

Concrete Strategies are the individual algorithm implementations. They all satisfy the same interface so the context treats them uniformly.

Classic OOP Implementation

Here’s a payment processing example, one of the most common use cases:

// Strategy Interface (implicit via duck typing)
class PaymentStrategy {
  pay(amount) {
    throw new Error('Method pay() must be implemented');
  }
}

// Concrete Strategies
class CreditCardPayment extends PaymentStrategy {
  constructor(cardNumber, cvv) {
    super();
    this.cardNumber = cardNumber;
    this.cvv = cvv;
  }

  pay(amount) {
    return `Paid ${amount} using Credit Card ${this.cardNumber.slice(-4)}`;
  }
}

class PayPalPayment extends PaymentStrategy {
  constructor(email) {
    super();
    this.email = email;
  }

  pay(amount) {
    return `Paid ${amount} using PayPal account ${this.email}`;
  }
}

class BankTransferPayment extends PaymentStrategy {
  constructor(accountNumber, routingNumber) {
    super();
    this.accountNumber = accountNumber;
    this.routingNumber = routingNumber;
  }

  pay(amount) {
    return `Paid ${amount} via bank transfer ${this.accountNumber.slice(-4)}`;
  }
}

Each concrete strategy owns its own configuration — card number, email, account number — and exposes a single pay method. The base class isn’t strictly necessary in JavaScript, but it serves as documentation and catches missing implementations early through the thrown error.

The context is where the pattern earns its keep. It holds items and a strategy reference, and delegates the actual payment work:

// Context
class ShoppingCart {
  constructor() {
    this.items = [];
    this.paymentStrategy = null;
  }

  addItem(item, price) {
    this.items.push({ item, price });
  }

  setPaymentStrategy(strategy) {
    if (!(strategy instanceof PaymentStrategy)) {
      throw new Error('Strategy must be a PaymentStrategy instance');
    }
    this.paymentStrategy = strategy;
  }

  checkout() {
    const total = this.items.reduce((sum, i) => sum + i.price, 0);
    if (!this.paymentStrategy) {
      throw new Error('No payment strategy set');
    }
    return this.paymentStrategy.pay(total);
  }
}

// Usage
const cart = new ShoppingCart();
cart.addItem('Keyboard', 149);
cart.addItem('Mouse', 59);

cart.setPaymentStrategy(new CreditCardPayment('4111111111111111', '123'));
console.log(cart.checkout());
// Paid 208 using Credit Card 1111

cart.setPaymentStrategy(new PayPalPayment('alex@example.com'));
console.log(cart.checkout());
// Paid 208 using PayPal account alex@example.com

cart.setPaymentStrategy(new BankTransferPayment('1234567890', '021000021'));
console.log(cart.checkout());
// Paid 208 via bank transfer 7890

This keeps the cart simple: it only knows about the strategy interface, not the details of any payment method.

Functional strategy pattern

When each strategy only needs a single method, you can skip classes entirely and use plain objects or functions. This is idiomatic JavaScript:

// Strategies as plain functions
const deliveryStrategies = {
  rush: (address) => `Rush → ${address} (1-2 days)`,
  standard: (address) => `Standard → ${address} (5-7 days)`,
  economy: (address) => `Economy → ${address} (10-14 days)`,
};

// Context factory
function createShippingCalculator(strategyFn) {
  return {
    calculate(address) {
      if (typeof strategyFn !== 'function') {
        throw new Error('Invalid strategy');
      }
      return strategyFn(address);
    },
    setStrategy(newStrategy) {
      strategyFn = newStrategy;
    }
  };
}

// Usage
const shipping = createShippingCalculator(deliveryStrategies.standard);
console.log(shipping.calculate('742 Evergreen Terrace'));
// Standard → 742 Evergreen Terrace (5-7 days)

shipping.setStrategy(deliveryStrategies.rush);
console.log(shipping.calculate('742 Evergreen Terrace'));
// Rush → 742 Evergreen Terrace (1-2 days)

This approach works well when your algorithms are simple enough to express as pure functions. No class inheritance, no ceremony.

Real-world example: form validation

Validation is a natural fit for the Strategy Pattern because you often need different rules for different fields, and those rules change independently:

// Strategy factories — functions that return validators
const validators = {
  required: () => (value) => {
    const isValid = value !== null && value !== undefined && value.trim().length > 0;
    return isValid ? null : 'This field is required';
  },

  email: () => (value) => {
    const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    return pattern.test(value) ? null : 'Enter a valid email address';
  },

  minLength: (min) => (value) => {
    return value.length >= min ? null : `Must be at least ${min} characters`;
  },

  maxLength: (max) => (value) => {
    return value.length <= max ? null : `Must be no more than ${max} characters`;
  },

  pattern: (regex, message) => (value) => {
    return regex.test(value) ? null : message;
  }
};

Each validator factory returns a function that takes a value and returns either null (valid) or an error string. The factories accept configuration parameters — min for length checks, regex and message for pattern matching — so you can reuse the same validator logic across different fields with different thresholds.

The form field context collects validators and runs them as a batch:

// Context
function createFormField(initialValue, ...fieldValidators) {
  return {
    value: initialValue,
    validate() {
      const errors = fieldValidators
        .map((validator) => validator(initialValue))
        .filter(Boolean);
      return {
        isValid: errors.length === 0,
        errors
      };
    }
  };
}

// Usage
const emailField = createFormField(
  'not-an-email',
  validators.required(),
  validators.email()
);

const result = emailField.validate();
console.log(result.isValid);     // false
console.log(result.errors);       // ['Enter a valid email address']

const passwordField = createFormField(
  'sh0rt',
  validators.required(),
  validators.minLength(8),
  validators.pattern(/[A-Z]/, 'Must contain an uppercase letter'),
  validators.pattern(/[0-9]/, 'Must contain a number')
);

const pwResult = passwordField.validate();
console.log(pwResult.isValid);    // false
console.log(pwResult.errors);
// ['Must be at least 8 characters', 'Must contain an uppercase letter']

Each validator is a strategy. The form field doesn’t know or care how validation works; it just runs whatever strategies you give it. You can combine validators freely, and adding a new one doesn’t require touching the field implementation.

Common mistakes and gotchas

Strategy explosion. If you find yourself creating dozens of tiny strategy classes that each do almost nothing, you’ve gone too far. Group related strategies. If a strategy needs configuration, use a factory function to create parameterized instances.

Shared mutable state. Strategies should be stateless or own their state entirely. If two strategies share a reference to a mutable object, they’ll interfere with each other in ways that are hard to debug. Pass all required data through the strategy’s method parameters.

Overusing the pattern. If you have two behaviors that will never grow, a simple conditional is clearer than the full pattern. The Strategy Pattern pays off when behaviors vary and change independently over time. If the logic is stable and likely to stay that way, skip the indirection.

Missing strategy handling. Always decide what happens when no strategy is set. Throwing an error (as shown above) is better than silent failure or returning undefined. For cases where a default makes sense, set it in the context’s constructor.

Context knows too much. The context should only interact with the strategy through its defined interface. If you find the context accessing strategy properties directly, the boundary has leaked.

How strategy differs from state

People often confuse Strategy with State, but they’re solving different problems.

State objects encapsulate state-specific behavior and the context often transitions between states itself. The context manages which state is active and when to switch, which is more involved than the simple delegation used in the Strategy Pattern.

Strategy objects encapsulate algorithms. The context delegates to the strategy and that’s the whole relationship; it doesn’t manage strategy transitions. The client code typically decides which strategy to use and when.

Think of it this way: Strategy is about swapping the algorithm the context runs. State is about modeling the context as being in one of several states, where each state influences behavior.

Pick the right level of indirection

The Strategy Pattern works best when the choice of behavior changes independently from the code that uses it. Pricing rules, sort orders, validation checks, and delivery options are all good examples. If the variation is small and unlikely to grow, a plain conditional may be easier to read. The pattern earns its keep when the list of behaviors is likely to change, because the context stays stable while the strategies evolve around it.

Test strategies in isolation

One quiet benefit of the pattern is that each strategy becomes easy to exercise on its own. You can feed a single strategy a known input and assert on a single output without building the full context around it. That makes tests short and precise. If a strategy starts reaching into shared globals or mutating outside data, the boundary has gone fuzzy, which is usually a sign to simplify the design.

When to Use

The Strategy Pattern makes sense when:

  • You have multiple ways to do the same thing, and you need to pick one at runtime
  • You add or change algorithms more often than you change the context itself
  • You want to avoid massive switch statements or if/else chains
  • Different algorithm variants need different configurations
  • You want to test each algorithm in isolation

The payment example is classic, but you’ll also see it in sorting (choosing QuickSort vs MergeSort based on data characteristics), routing (fastest vs shortest vs scenic routes), compression (ZIP vs RAR vs tar), and authentication (API key vs OAuth vs JWT strategies).

Keep strategies narrow

A strategy should describe one algorithm clearly rather than try to become a mini service object. When each strategy stays focused, the context can swap them in and out without needing to know extra details. That keeps the pattern useful in real projects because the behavior stays easy to reason about and the boundary stays simple to test.

Prefer configuration over duplication

If two strategies only differ by a small rule or threshold, a factory function is often better than another class. The strategy pattern is strongest when the algorithms are meaningfully different. When the variation is only a parameter, a configurable function keeps the code shorter and easier to scan.

Next steps

Now that you can swap algorithms at runtime with the Strategy Pattern, try applying it to a real codebase. Look for a class with a growing switch statement — maybe a payment handler, a sorting routine, or a set of validation rules. Extract each branch into its own strategy object, wire them through a context, and compare the readability before and after.

The Command Pattern pairs well with Strategy when you need to queue, log, or undo those algorithm choices. If your strategies need to notify other parts of the system about what they did, the Observer Pattern handles that without coupling the strategy to the listener.

See Also

  • Command Pattern, encapsulate requests as objects, often paired with Strategy in undo/redo systems
  • Observer Pattern, decoupled communication between objects
  • State Machines, model an entity that transitions between discrete states