Strategy Pattern in TypeScript: Generic Strategies

The Strategy pattern lets you swap algorithms at runtime without changing the code that uses them. You define a family of algorithms, encapsulate each one, and make them interchangeable. It's one of...

Key Insights

  • Generic strategies combine the Strategy pattern’s runtime flexibility with TypeScript’s compile-time type safety, eliminating type assertions and runtime errors when swapping algorithms.
  • Type constraints on generic parameters let you enforce domain-specific contracts while keeping strategies reusable across different data types.
  • Strategy composition with generics enables type-safe pipelines where the output of one strategy flows directly into the next, catching incompatibilities at compile time.

Introduction to Generic Strategies

The Strategy pattern lets you swap algorithms at runtime without changing the code that uses them. You define a family of algorithms, encapsulate each one, and make them interchangeable. It’s one of the most practical patterns in the Gang of Four catalog.

But the basic implementation in TypeScript often relies on any types or awkward type assertions. When your strategies operate on different data types, you lose the type safety that makes TypeScript valuable in the first place.

Generic strategies solve this problem. By parameterizing your strategy interfaces and classes with type variables, you get full IntelliSense support, compile-time error checking, and the flexibility to reuse the same pattern across different domains. Whether you’re building data transformers, validators, serializers, or sorting algorithms, generic strategies keep your code both flexible and type-safe.

Designing a Generic Strategy Interface

The foundation of any strategy implementation is the interface. With generics, you define type parameters that flow through the entire strategy hierarchy.

interface IStrategy<TInput, TOutput> {
  execute(input: TInput): TOutput;
}

This simple interface says: “A strategy takes something of type TInput and produces something of type TOutput.” The types are determined when you implement the interface, not when you define it.

For domain-specific behavior, you can constrain the generic types:

interface Identifiable {
  id: string;
}

interface IRepositoryStrategy<T extends Identifiable> {
  find(id: string): T | null;
  save(entity: T): void;
  delete(entity: T): void;
}

The extends Identifiable constraint guarantees that any type used with this strategy has an id property. You can’t accidentally use it with incompatible types.

For asynchronous operations, extend the pattern:

interface IAsyncStrategy<TInput, TOutput> {
  execute(input: TInput): Promise<TOutput>;
}

Implementing Generic Strategy Classes

Concrete strategies implement the generic interface with specific or preserved type parameters. Here’s a practical example with sorting strategies:

interface ISortStrategy<T> {
  sort(items: T[], compareFn: (a: T, b: T) => number): T[];
}

class QuickSortStrategy<T> implements ISortStrategy<T> {
  sort(items: T[], compareFn: (a: T, b: T) => number): T[] {
    if (items.length <= 1) return [...items];
    
    const pivot = items[Math.floor(items.length / 2)];
    const left = items.filter(item => compareFn(item, pivot) < 0);
    const middle = items.filter(item => compareFn(item, pivot) === 0);
    const right = items.filter(item => compareFn(item, pivot) > 0);
    
    return [
      ...this.sort(left, compareFn),
      ...middle,
      ...this.sort(right, compareFn),
    ];
  }
}

class MergeSortStrategy<T> implements ISortStrategy<T> {
  sort(items: T[], compareFn: (a: T, b: T) => number): T[] {
    if (items.length <= 1) return [...items];
    
    const mid = Math.floor(items.length / 2);
    const left = this.sort(items.slice(0, mid), compareFn);
    const right = this.sort(items.slice(mid), compareFn);
    
    return this.merge(left, right, compareFn);
  }
  
  private merge(left: T[], right: T[], compareFn: (a: T, b: T) => number): T[] {
    const result: T[] = [];
    let leftIndex = 0;
    let rightIndex = 0;
    
    while (leftIndex < left.length && rightIndex < right.length) {
      if (compareFn(left[leftIndex], right[rightIndex]) <= 0) {
        result.push(left[leftIndex++]);
      } else {
        result.push(right[rightIndex++]);
      }
    }
    
    return [...result, ...left.slice(leftIndex), ...right.slice(rightIndex)];
  }
}

Both strategies work with any type T. The caller provides the comparison function, and the strategy handles the algorithm:

interface User {
  name: string;
  age: number;
}

const users: User[] = [
  { name: 'Alice', age: 30 },
  { name: 'Bob', age: 25 },
  { name: 'Charlie', age: 35 },
];

const quickSort = new QuickSortStrategy<User>();
const sortedByAge = quickSort.sort(users, (a, b) => a.age - b.age);

The Generic Context Class

The context class holds a reference to the current strategy and delegates work to it. With generics, you preserve type information throughout:

class StrategyContext<TInput, TOutput> {
  private strategy: IStrategy<TInput, TOutput>;

  constructor(strategy: IStrategy<TInput, TOutput>) {
    this.strategy = strategy;
  }

  setStrategy(strategy: IStrategy<TInput, TOutput>): void {
    this.strategy = strategy;
  }

  executeStrategy(input: TInput): TOutput {
    return this.strategy.execute(input);
  }
}

For dependency injection scenarios, you might want a factory-based approach:

type StrategyFactory<TInput, TOutput> = () => IStrategy<TInput, TOutput>;

class LazyStrategyContext<TInput, TOutput> {
  private strategyFactory: StrategyFactory<TInput, TOutput>;
  private cachedStrategy: IStrategy<TInput, TOutput> | null = null;

  constructor(factory: StrategyFactory<TInput, TOutput>) {
    this.strategyFactory = factory;
  }

  private getStrategy(): IStrategy<TInput, TOutput> {
    if (!this.cachedStrategy) {
      this.cachedStrategy = this.strategyFactory();
    }
    return this.cachedStrategy;
  }

  execute(input: TInput): TOutput {
    return this.getStrategy().execute(input);
  }

  invalidateCache(): void {
    this.cachedStrategy = null;
  }
}

This defers strategy instantiation until first use—useful when strategies have expensive setup costs or external dependencies.

Advanced Patterns: Composing Generic Strategies

The real power of generic strategies emerges when you compose them. A pipeline of strategies can transform data through multiple stages, with TypeScript ensuring type compatibility at each step.

class StrategyPipeline<TInput, TOutput> {
  private constructor(
    private readonly executor: (input: TInput) => TOutput
  ) {}

  static start<T>(): StrategyPipeline<T, T> {
    return new StrategyPipeline((input: T) => input);
  }

  pipe<TNext>(
    strategy: IStrategy<TOutput, TNext>
  ): StrategyPipeline<TInput, TNext> {
    return new StrategyPipeline((input: TInput) => {
      const intermediate = this.executor(input);
      return strategy.execute(intermediate);
    });
  }

  execute(input: TInput): TOutput {
    return this.executor(input);
  }
}

Now you can chain strategies with full type safety:

// Define transformation strategies
class ParseJsonStrategy implements IStrategy<string, unknown> {
  execute(input: string): unknown {
    return JSON.parse(input);
  }
}

class ValidateUserStrategy implements IStrategy<unknown, User> {
  execute(input: unknown): User {
    const obj = input as Record<string, unknown>;
    if (typeof obj.name !== 'string' || typeof obj.age !== 'number') {
      throw new Error('Invalid user data');
    }
    return { name: obj.name, age: obj.age };
  }
}

class NormalizeUserStrategy implements IStrategy<User, User> {
  execute(input: User): User {
    return {
      name: input.name.trim().toLowerCase(),
      age: input.age,
    };
  }
}

// Build the pipeline
const userPipeline = StrategyPipeline.start<string>()
  .pipe(new ParseJsonStrategy())
  .pipe(new ValidateUserStrategy())
  .pipe(new NormalizeUserStrategy());

const user = userPipeline.execute('{"name": " ALICE ", "age": 30}');
// user is typed as User: { name: 'alice', age: 30 }

If you try to pipe strategies with incompatible types, TypeScript catches the error at compile time.

Practical Application: Generic Validation Strategies

Let’s build a complete validation system that demonstrates generic strategies in a real-world scenario:

interface ValidationResult {
  isValid: boolean;
  errors: string[];
}

interface IValidationStrategy<TFormData> {
  validate(data: TFormData): ValidationResult;
}

// Composable validator that runs multiple strategies
class CompositeValidator<TFormData> implements IValidationStrategy<TFormData> {
  constructor(private strategies: IValidationStrategy<TFormData>[]) {}

  validate(data: TFormData): ValidationResult {
    const allErrors: string[] = [];
    
    for (const strategy of this.strategies) {
      const result = strategy.validate(data);
      allErrors.push(...result.errors);
    }

    return {
      isValid: allErrors.length === 0,
      errors: allErrors,
    };
  }
}

// Specific form types
interface LoginForm {
  email: string;
  password: string;
}

interface RegistrationForm {
  email: string;
  password: string;
  confirmPassword: string;
  acceptedTerms: boolean;
}

// Reusable email validation
class EmailValidationStrategy<T extends { email: string }> 
  implements IValidationStrategy<T> {
  
  validate(data: T): ValidationResult {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    const isValid = emailRegex.test(data.email);
    
    return {
      isValid,
      errors: isValid ? [] : ['Invalid email format'],
    };
  }
}

// Password strength validation
class PasswordStrengthStrategy<T extends { password: string }> 
  implements IValidationStrategy<T> {
  
  constructor(private minLength: number = 8) {}

  validate(data: T): ValidationResult {
    const errors: string[] = [];
    
    if (data.password.length < this.minLength) {
      errors.push(`Password must be at least ${this.minLength} characters`);
    }
    if (!/[A-Z]/.test(data.password)) {
      errors.push('Password must contain an uppercase letter');
    }
    if (!/[0-9]/.test(data.password)) {
      errors.push('Password must contain a number');
    }

    return { isValid: errors.length === 0, errors };
  }
}

// Registration-specific validation
class PasswordMatchStrategy implements IValidationStrategy<RegistrationForm> {
  validate(data: RegistrationForm): ValidationResult {
    const isValid = data.password === data.confirmPassword;
    return {
      isValid,
      errors: isValid ? [] : ['Passwords do not match'],
    };
  }
}

class TermsAcceptedStrategy implements IValidationStrategy<RegistrationForm> {
  validate(data: RegistrationForm): ValidationResult {
    return {
      isValid: data.acceptedTerms,
      errors: data.acceptedTerms ? [] : ['You must accept the terms'],
    };
  }
}

Now assemble validators for different forms:

// Login form validator
const loginValidator = new CompositeValidator<LoginForm>([
  new EmailValidationStrategy(),
  new PasswordStrengthStrategy(6), // Relaxed for login
]);

// Registration form validator
const registrationValidator = new CompositeValidator<RegistrationForm>([
  new EmailValidationStrategy(),
  new PasswordStrengthStrategy(10), // Stricter for registration
  new PasswordMatchStrategy(),
  new TermsAcceptedStrategy(),
]);

// Usage
const loginResult = loginValidator.validate({
  email: 'user@example.com',
  password: 'weak',
});

const regResult = registrationValidator.validate({
  email: 'invalid-email',
  password: 'short',
  confirmPassword: 'different',
  acceptedTerms: false,
});

The generic constraints ensure EmailValidationStrategy works with any form containing an email field, while PasswordMatchStrategy only works with RegistrationForm.

Trade-offs and Best Practices

Generic strategies add complexity. Use them when you have multiple data types flowing through the same algorithmic structure, when type safety at strategy boundaries prevents bugs, or when you’re building reusable libraries consumed by other developers.

Avoid generic strategies when you have a single, stable input/output type (just use concrete types), when the added type parameters obscure the code’s intent, or when you’re prototyping and the types are still in flux.

For testing, create simple mock strategies that implement your generic interfaces. The type system helps here—if your mock compiles, it satisfies the contract:

class MockStrategy<T> implements IStrategy<T, T> {
  public callCount = 0;
  
  execute(input: T): T {
    this.callCount++;
    return input;
  }
}

Generic strategies shine in data processing pipelines, validation frameworks, serialization systems, and anywhere you need to swap algorithms while maintaining type safety. Start with concrete types, identify the patterns, then extract generics where they reduce duplication without sacrificing clarity.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.