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:
- npm init -y
- npm install -D typescript@latest
- npx tsc --init
- 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.
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.
interface Strategy<I, O> {
execute: (input: I) => O
}
type StrategyMap<K extends string, I, O> = Record<K, Strategy<I, O>>Example usage for payments:
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.
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.
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.
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.
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.
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.
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.
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.
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:
type InputOf<S> = S extends { execute: (i: infer I) => any } ? I : never
type OutputOf<S> = S extends { execute: (...a: any[]) => infer O } ? O : neverUse 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
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
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.
