CodeFixesHub
    programming tutorial

    Typing Strategy Pattern Implementations in TypeScript

    Master typed Strategy pattern implementations in TypeScript—learn generics, guards, composition, and performance tips. Follow the full step-by-step tutorial now.

    article details

    Quick Overview

    TypeScript
    Category
    Sep 11
    Published
    20
    Min Read
    2K
    Words
    article summary

    Master typed Strategy pattern implementations in TypeScript—learn generics, guards, composition, and performance tips. Follow the full step-by-step tutorial now.

    Typing Strategy Pattern Implementations in TypeScript

    Introduction

    The Strategy pattern helps you select an algorithm at runtime by encapsulating algorithms as interchangeable objects. In TypeScript, a plain JavaScript implementation can be brittle: lack of type safety means strategies can be miswired, inputs misused, and bugs only surface at runtime. For intermediate developers building libraries, frameworks, or modular apps, ensuring compile time safety while keeping flexibility is essential.

    In this tutorial you will learn how to design and type Strategy pattern implementations in TypeScript that are safe, composable, and performant. We will cover interface design, generics for inputs and outputs, runtime guards and assertion functions, composing strategies with higher order functions, registering strategies with typed registries, and patterns for async strategies. Practical code samples and step-by-step guidance will show how to migrate an untyped design into a robust typed API that improves developer experience and reduces bugs.

    By the end you will be able to type strategy interfaces, implement factories and registries for strategies, write safe strategy selectors with runtime checks, and apply best practices for performance and testability. You will also find links to related typing topics that complement this pattern for real world projects.

    Background & Context

    The Strategy pattern is a behavioral design pattern that enables choosing an algorithm at runtime by delegating behavior to strategy objects that follow a common interface. In TypeScript, typing strategies requires thinking about the shape of inputs and outputs, whether strategies are synchronous or asynchronous, and how they will be composed.

    Strong typing prevents accidental misuse: generic signatures can ensure a strategy only accepts expected data, union narrowing and type predicates can help discriminate implementations, and assertion functions can enforce runtime invariants where necessary. Related structural patterns such as factories, builders, and singletons often appear alongside strategies, so understanding how these patterns are typed is helpful; for deeper reading see our pieces on Typing Factory Pattern Implementations in TypeScript and Typing Singleton Pattern Implementations in TypeScript.

    Key Takeaways

    • How to design type-safe strategy interfaces and generics
    • When and how to use type predicates and assertion functions to narrow strategy types
    • Composing strategies with higher-order functions and factories
    • Registering strategies in typed registries and optional singletons
    • Handling async strategies and typed async iterables
    • Performance considerations and memoization patterns

    For advanced function composition, our guide on Typing Higher-Order Functions in TypeScript — Advanced Scenarios has deep patterns that apply directly to composing strategies.

    Prerequisites & Setup

    This guide assumes you are comfortable with TypeScript basics, generics, interfaces, and conditional types. Recommended TypeScript version is 4.2 or newer for nicer variadic tuple and inference support. You will also benefit from knowledge of higher-order functions and runtime guards. If you need refreshers on specific helper techniques, check our pieces on Using Assertion Functions in TypeScript (TS 3.7+) and Using Type Predicates for Filtering Arrays in TypeScript.

    Set up a minimal project:

    1. npm init -y
    2. npm install -D typescript@latest
    3. npx tsc --init
    4. Create src/ and open an editor that supports TypeScript language server

    Now let us step through concrete implementations.

    Main Tutorial Sections

    1. A simple untyped Strategy example

    Start with an untyped JavaScript pattern to clarify goals. Imagine payment processing strategies keyed by provider id.

    ts
    const strategies = {
      stripe: (payload) => { /* charge via stripe */ },
      paypal: (payload) => { /* charge via paypal */ },
    }
    
    function charge(provider, payload) {
      return strategies[provider](payload)
    }

    Problems: provider misspelling, incorrect payload shapes, async handling. Next we add types.

    2. Basic typed strategy interface

    Define a minimal typed contract so each strategy receives a typed input and returns a typed output. Make it generic to reuse.

    ts
    interface Strategy<I, O> {
      execute: (input: I) => O
    }
    
    type StrategyMap<K extends string, I, O> = Record<K, Strategy<I, O>>

    Example usage for payments:

    ts
    type PaymentInput = { amount: number; currency: string }
    type PaymentResult = { success: boolean; id?: string }
    
    const strategies: StrategyMap<'stripe'|'paypal', PaymentInput, Promise<PaymentResult>> = { /* ... */ }

    This prevents wiring mistakes and clarifies IO types.

    3. Leveraging generics for per-strategy input types

    Often strategies need different input shapes. Use mapped types to associate inputs and outputs per key.

    ts
    type StrategyFunction<I, O> = (input: I) => O
    
    type StrategyRegistry<Keys extends string, Inputs, Outputs> = {
      [K in Keys]: StrategyFunction<Inputs[K], Outputs[K]>
    }
    
    // Example
    type Keys = 'stripe'|'bank'
    interface Inputs { stripe: { token: string }; bank: { account: string } }
    interface Outputs { stripe: PaymentResult; bank: PaymentResult }

    This keeps per-provider types accurate and allows the compiler to infer the correct input when selecting a key.

    4. Discriminated unions and narrowing strategies

    When using a union of strategy types, discriminants let TypeScript narrow types. Create a union of labeled strategy objects.

    ts
    type StripeStrategy = { kind: 'stripe'; execute: (i: { token: string }) => PaymentResult }
    type BankStrategy = { kind: 'bank'; execute: (i: { account: string }) => PaymentResult }
    
    type AnyStrategy = StripeStrategy | BankStrategy
    
    function run(s: AnyStrategy, input: any) {
      if (s.kind === 'stripe') return s.execute(input)
      if (s.kind === 'bank') return s.execute(input)
    }

    For more advanced runtime checks and assertions, our article on Using Assertion Functions in TypeScript (TS 3.7+) explains how to create compile-time narrowing backed by runtime code.

    5. Runtime guards and type predicates

    Static types are great, but sometimes you need runtime guards that also refine types. Write type predicates to validate payloads before executing a strategy.

    ts
    function isPaymentInput(x: unknown): x is PaymentInput {
      return typeof x === 'object' && x !== null && 'amount' in x && 'currency' in x
    }
    
    function safeExecute(s: Strategy<any, any>, payload: unknown) {
      if (!isPaymentInput(payload)) throw new Error('Invalid input')
      return s.execute(payload)
    }

    Use Using Type Predicates for Filtering Arrays in TypeScript if you need to validate and filter lists of strategy inputs.

    6. Composing strategies with higher-order functions

    Often you want to wrap or decorate strategies. Higher-order functions let you build composed behaviors like logging, retry, or caching.

    ts
    function withLogging<I, O>(s: Strategy<I, O>): Strategy<I, O> {
      return {
        async execute(input: I) {
          console.log('input', input)
          const result = await s.execute(input as any)
          console.log('result', result)
          return result
        }
      }
    }

    For complex composition patterns and type-preserving wrappers, see Typing Higher-Order Functions in TypeScript — Advanced Scenarios which covers preserving 'this' and variadic tuples in wrappers.

    7. Strategy factories and registration

    Factories produce strategies dynamically; registries store them. Type factories so their outputs are typed.

    ts
    type StrategyFactory<K extends string, I, O> = (key: K) => Strategy<I, O>
    
    const createPaymentStrategy: StrategyFactory<'stripe'|'bank', PaymentInput, Promise<PaymentResult>> = (key) => {
      if (key === 'stripe') return { execute: async (i) => ({ success: true, id: 's' }) }
      return { execute: async (i) => ({ success: true, id: 'b' }) }
    }

    When building registries that live app-wide, consider typed singletons—our guide on Typing Singleton Pattern Implementations in TypeScript shows patterns for safe global registries.

    8. Async strategies and typed async iterables

    Strategies may perform IO and be async. Type the execute method to return Promise or AsyncIterator when streaming results.

    ts
    interface AsyncStrategy<I, O> { execute: (i: I) => Promise<O> }
    
    interface StreamStrategy<I, O> { execute: (i: I) => AsyncIterable<O> }
    
    async function processStream<S extends StreamStrategy<any, any>>(s: S, input: Parameters<S['execute']>[0]) {
      for await (const item of s.execute(input)) {
        // handle item
      }
    }

    For patterns and typing tips on streaming and iteration, see Typing Async Iterators and Async Iterables in TypeScript — Practical Guide.

    9. Caching and memoization for strategy results

    To reduce repeated work, cache strategy outputs. Type-safe caching ensures keys and values align with the strategy IO types.

    ts
    function memoizeStrategy<I, O>(s: Strategy<I, Promise<O>>) {
      const cache = new Map<string, Promise<O>>()
      return {
        execute(input: I) {
          const key = JSON.stringify(input)
          if (!cache.has(key)) cache.set(key, s.execute(input))
          return cache.get(key)!
        }
      }
    }

    For patterns and performance tradeoffs, reference Typing Cache Mechanisms: A Practical TypeScript Guide and Typing Memoization Functions in TypeScript.

    10. Strategy registries keyed by symbols and advanced keys

    Using symbol keys or private keys avoids collisions when plugins register strategies. Typing symbol-based objects is slightly different.

    ts
    const STRATEGY_KEY = Symbol('payment')
    
    interface SymbolRegistry<T> {
      get(key: symbol): T | undefined
      set(key: symbol, value: T): void
    }

    To understand typing symbol keys and patterns, see Typing Symbols as Object Keys in TypeScript — Comprehensive Guide.

    Advanced Techniques

    Once basics are in place, you can push types further with conditional types and inference helpers. Create helper types that extract input and output types from a strategy object, for example:

    ts
    type InputOf<S> = S extends { execute: (i: infer I) => any } ? I : never
    type OutputOf<S> = S extends { execute: (...a: any[]) => infer O } ? O : never

    Use these helpers for typed registries that infer strategy signatures on lookup. Also consider technique of branded payloads when several strategies share structurally compatible shapes but should not be mixed; branding prevents accidental cross-use.

    When composing multiple strategies into pipelines, prefer tuple-based types and variadic mapping to preserve order and types. For composition helpers that keep types intact, refer to advanced HOF patterns in Typing Higher-Order Functions in TypeScript — Advanced Scenarios.

    For performance-sensitive strategies, avoid JSON.stringify for cache keys on hot paths; use stable hashing or tuple-based keys and typed maps. Where strategies can be heavy, combine caching with debounce or throttling patterns covered in Typing Debounce and Throttling Functions in TypeScript.

    If strategies need to expose lazy getters or computed properties, type getters and setters to preserve behavior using guidance from Typing Getters and Setters in Classes and Objects.

    Best Practices & Common Pitfalls

    Dos:

    • Use explicit generics on public APIs so callers see expected input and output types.
    • Prefer discriminated unions for strategy implementations to aid narrowing.
    • Provide runtime guards when payload validation matters for security or correctness.
    • Offer both sync and async strategy types if your API supports both and document differences.

    Donts:

    • Avoid relying on structural equivalence alone when different strategies accept similar shapes; use branding or discriminants.
    • Do not use any excessively; prefer narrow unknown checks and predicates.
    • Avoid global mutable registries without clear initialization and teardown strategies; if you must use a global, follow patterns from Typing Singleton Pattern Implementations in TypeScript.

    Troubleshooting tips:

    • If TypeScript cannot infer a strategy input, add explicit generic annotations to the registry or factory function.
    • For mismatched union narrowing, ensure discriminant keys are literal string types rather than generic string.
    • If wrapping functions lose 'this' types, review techniques in Typing Functions That Modify this (ThisParameterType, OmitThisParameter).

    Real-World Applications

    Strategy pattern typing is useful across many domains: payment gateways, serialization formats, pluggable validation rules, feature toggles, command dispatchers, and AI model runners. When designing microservices, typed strategies let services swap algorithmic implementations without changing the public contract. In UI components, strategies can drive rendering or animation choices where typed inputs prevent regressions.

    For caches around strategies in high throughput systems, consult Typing Cache Mechanisms: A Practical TypeScript Guide and pair memoization with Typing Memoization Functions in TypeScript to reduce redundant computations safely.

    Conclusion & Next Steps

    Typing Strategy pattern implementations in TypeScript is about balancing flexibility and safety. Start by defining clear generic contracts, use discriminants and type predicates to narrow implementations, and compose behaviors with typed higher-order functions. Next steps: implement a small registry in a real project, add tests to verify type contracts, and read the linked deep dives to strengthen adjacent skills.

    Recommended next reads: Typing Factory Pattern Implementations in TypeScript and Typing Higher-Order Functions in TypeScript — Advanced Scenarios.

    Enhanced FAQ

    Q1: When should I prefer union-per-key mapped types over discriminated unions for strategies?

    A1: Use mapped types when you have a set of known keys where each key has its own distinct input/output types and lookups are performed by key. Use discriminated unions when behaviour is data-driven and you will switch on the discriminant. Mapped types provide stronger per-key inference for registries.

    Q2: How can I type a registry where adding a new strategy must update associated input types?

    A2: Use a generic registry type that accepts a mapping interface, e.g. Registry, where Mapping is a record of key to { input, output }. Then your registry lookup function can be typed as (k: K, input: Mapping[K]["input"]) => Mapping[K]["output"]. This forces updates when new strategies are added.

    Q3: Are assertion functions necessary if TypeScript types already exist?

    A3: Assertion functions are necessary when runtime data arrives from outside TypeScript control, like network payloads. They bridge runtime validation with static narrowing so the compiler understands the refined type after the check. See Using Assertion Functions in TypeScript (TS 3.7+) for patterns.

    Q4: How do I type strategies that stream events or partial results?

    A4: Expose AsyncIterable or AsyncGenerator from the execute method, e.g. execute: (i: I) => AsyncIterable. Consumers can use for await...of to iterate. For typing patterns and examples, consult Typing Async Iterators and Async Iterables in TypeScript — Practical Guide.

    Q5: What is a safe approach to caching strategy outputs with typed keys?

    A5: Prefer typed map keys using tuples or dedicated key objects with stable hashing. Avoid JSON.stringify on rapidly changing objects. Use a typed wrapper like Map<KeyType, ValueType> where KeyType is a tuple of primitive parts or a stable identifier.

    Q6: How can I ensure decorated strategies preserve original types when wrapped?

    A6: Use generic wrappers that mirror the wrapped signature, e.g. function wrap<S extends Strategy<any, any>>(s: S): S { /* ... */ } and return typed results. For complex wrappers that add behavior, preserve parameter and return type inference using conditional types and helper types.

    Q7: Should registry lookups throw or return undefined when a strategy is missing?

    A7: Prefer returning undefined for optional plugins and throwing for required enumerations. Expose both options: a typed get that returns Strategy | undefined, and a getOrThrow helper that returns Strategy and throws with an informative message to aid debugging.

    Q8: How do I handle backward compatibility when evolving strategy input types?

    A8: Use versioned strategies or adapters that accept old payloads and transform them to the new shape. Keep a thin adapter layer typed to both old and new interfaces during migration and remove it once all callers are updated.

    Q9: Can strategies be singletons or should they be created per use?

    A9: It depends. Stateless strategies are safe as singletons. Stateful or context-dependent strategies should be instantiated per use. If you need a global registry of singleton strategies, use patterns from Typing Singleton Pattern Implementations in TypeScript to manage lifecycle and typing.

    Q10: What utility patterns help when I need to pick a strategy based on runtime conditions?

    A10: Build a typed selector function that narrows by discriminant and returns a strategy typed to the expected input. Combine with type predicates to validate runtime data. For complex selection logic, factor selection into small typed functions and test them thoroughly.


    If you want example repositories or a small reference implementation to clone and experiment with, tell me which domain you prefer, for example payment processing, image processing, or validation engines, and I will produce a starter project with typed strategies and tests.

    article completed

    Great Work!

    You've successfully completed this TypeScript tutorial. Ready to explore more concepts and enhance your development skills?

    share this article

    Found This Helpful?

    Share this TypeScript tutorial with your network and help other developers learn!

    continue learning

    Related Articles

    Discover more programming tutorials and solutions related to this topic.

    No related articles found.

    Try browsing our categories for more content.

    Content Sync Status
    Offline
    Changes: 0
    Last sync: 11:19:59 PM
    Next sync: 60s
    Loading CodeFixesHub...