Typing Memoization Functions in TypeScript
Introduction
Memoization is a powerful performance optimization: store results of expensive function calls and return the cached result when inputs repeat. For intermediate TypeScript developers, implementing memoization is straightforward in JavaScript — but making it type-safe, flexible, and memory-efficient in TypeScript takes extra care. Typed memoization helps preserve function signatures, preserves correct this behavior, and avoids subtle runtime bugs caused by poor cache keys or incorrect types.
In this tutorial you'll learn how to design and implement strongly-typed memoization utilities that work with: pure synchronous functions, functions with variable argument lists (variadic tuples), methods that rely on this, async functions (including caching in-flight promises), and functions that accept complex keys (objects, Dates, Symbols, BigInt). We'll cover trade-offs between simple string-keyed caches and robust nested Map/WeakMap solutions, show patterns that keep inference intact, and provide testing and debugging tips.
By the end of this guide you will be able to:
- Write a generic memoize wrapper that preserves parameter and return types
- Support variadic functions using tuple types safely
- Handle
thiscorrectly using TypeScript helpers - Choose appropriate cache data structures (Map vs WeakMap) and type them
- Memoize async functions while caching in-flight promises to avoid duplicate work
- Avoid common pitfalls (serialization collisions, memory leaks, untyped wrappers)
This article assumes intermediate familiarity with TypeScript generics, mapped types, and a comfortable understanding of Maps, WeakMaps and basic runtime JavaScript.
Background & Context
Memoization is useful for CPU-bound computations, repeated I/O calls with identical parameters, and pure functions whose return values depend only on input. The typical untyped memoize implementation serializes arguments (JSON.stringify) and uses the string as a cache key. That works for many cases, but it breaks for functions that accept non-JSON-safe values (like functions, Symbols, or BigInt), for object arguments where identity matters, and for methods relying on this.
TypeScript gives us tools (Parameters
For deep dives on related typing patterns you'll find it useful to review how to treat function parameter tuples and variadic rest signatures in TypeScript. See our guide on Typing Function Parameters as Tuples in TypeScript and Typing Functions That Accept a Variable Number of Arguments (Tuples and Rest) for background on tuple typing and rest parameters.
Key Takeaways
- Use TypeScript generic helpers (Parameters, ReturnType) to preserve function types when memoizing.
- Handle
thisby leveraging ThisParameterType and OmitThisParameter to keep correct method signatures. See our article on Typing Functions That Modifythis(ThisParameterType, OmitThisParameter) if you're unfamiliar with those utilities. - Prefer nested Map/WeakMap caches to stringified keys for correctness and memory safety when caching by object identity.
- For async memoization, cache in-flight Promises to deduplicate concurrent calls.
- Be mindful of keys that aren't JSON-safe (Symbols, BigInt, Date, RegExp) and type them appropriately; our guides on Typing Symbols as Object Keys in TypeScript — Comprehensive Guide and Typing BigInt in TypeScript: Practical Guide for Intermediate Developers are helpful references.
Prerequisites & Setup
Before proceeding you'll need:
- TypeScript 4.1+ (variadic tuple improvements help; 4.3+ recommended)
- Node.js >= 12 for runtime tests, though examples work in modern browsers too
- Basic tooling: a TypeScript project, ts-node or a build step
Create a minimal tsconfig with strict mode enabled to catch typing issues early:
{
"compilerOptions": {
"target": "ES2019",
"module": "commonjs",
"strict": true,
"esModuleInterop": true
}
}Now let's build typed memoization utilities step by step.
Main Tutorial Sections
1. A Minimal Typed Memoize (String-keyed)
Start with the simplest typed wrapper that uses a string key (via JSON.stringify). This preserves the function signature while providing a fast path for primitive-heavy functions.
function memoize<F extends (...args: any[]) => any>(fn: F): F {
const cache = new Map<string, ReturnType<F>>();
const wrapped = function (this: any, ...args: Parameters<F>): ReturnType<F> {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key) as ReturnType<F>;
const result = fn.apply(this, args) as ReturnType<F>;
cache.set(key, result);
return result;
};
return wrapped as F;
}Notes:
- We use Parameters
and ReturnType to preserve types. - Casting the wrapped function to F keeps DX, but doesn't handle
thisproperly for methods. We'll address that next. - JSON.stringify works for many simple cases but fails for Symbol, BigInt, functions, and non-deterministic object key order.
For better parameter typing patterns, see our article on Typing Function Parameters as Tuples in TypeScript.
2. Preserving this Correctly
If the original function uses this, the wrapper must allow the same this type. TypeScript provides helpers ThisParameterType and OmitThisParameter to help with this.
type Memoized<F extends (...args: any[]) => any> = (
this: ThisParameterType<F>,
...args: Parameters<OmitThisParameter<F>>
) => ReturnType<F>;
function memoizeThisAware<F extends (...args: any[]) => any>(fn: F): Memoized<F> {
const cache = new Map<string, ReturnType<F>>();
const wrapped = function (this: ThisParameterType<F>, ...args: Parameters<OmitThisParameter<F>>): ReturnType<F> {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key) as ReturnType<F>;
const result = fn.apply(this, args);
cache.set(key, result as ReturnType<F>);
return result as ReturnType<F>;
};
return wrapped as Memoized<F>;
}This preserves this and argument types; learn more about typing functions that modify this in our guide on Typing Functions That Modify this (ThisParameterType, OmitThisParameter).
3. Variadic Functions and Tuple-safe Keys
Many functions accept a variable number of arguments. When you rely on JSON.stringify you risk collisions and ordering issues. Instead, use a nested Map approach keyed by each argument. Typing nested caches with tuples guarantees that the cache shape reflects the function parameters.
Conceptually:
- Each argument level has a Map keyed by the argument value.
- The final level stores the result.
- When an argument is an object, use a WeakMap instead of Map for GC-friendly caching.
Implementation sketch:
type Key = unknown;
function makeKeyedMemoize<F extends (...args: any[]) => any>(fn: F) {
const root = new Map<Key, any>();
return function (this: any, ...args: Parameters<F>): ReturnType<F> {
let node: Map<Key, any> | WeakMap<object, any> = root;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const isLast = i === args.length - 1;
const mapForArg: Map<Key, any> | WeakMap<object, any> =
typeof arg === 'object' && arg !== null ? new WeakMap() : new Map();
const next = (node as Map<any, any>).get(arg) ?? mapForArg;
if (!((node as Map<any, any>).has(arg))) (node as Map<any, any>).set(arg, next);
if (isLast) {
if ((next as Map<any, any>).has('__result')) return (next as Map<any, any>).get('__result');
const result = fn.apply(this, args);
(next as Map<any, any>).set('__result', result);
return result;
}
node = next as any;
}
return fn.apply(this, args);
} as F;
}This is a simplified blueprint; production code needs careful typing. For deeper typed tuple patterns, see Typing Functions That Accept a Variable Number of Arguments (Tuples and Rest).
4. Typed Nested Map + WeakMap Implementation
Let's write a cleaner, typed implementation that mixes Map for primitives and WeakMap for objects.
type CacheNode<R> = Map<any, CacheNode<R> | R> | WeakMap<object, CacheNode<R> | R>;
function memoizeWithIdentity<F extends (...args: any[]) => any>(fn: F): F {
const root: CacheNode<ReturnType<F>> = new Map();
function getMapForArg(arg: any): CacheNode<ReturnType<F>> {
return typeof arg === 'object' && arg !== null ? new WeakMap() : new Map();
}
const wrapped = function (this: any, ...args: Parameters<F>): ReturnType<F> {
let node: CacheNode<ReturnType<F>> = root;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const isLast = i === args.length - 1;
const mapLike = node as any;
let next = mapLike.get(arg);
if (!next) {
next = isLast ? (Symbol('result') as any) : getMapForArg(arg);
mapLike.set(arg, next);
}
if (isLast) {
if (next !== undefined && typeof next !== 'object') return next as ReturnType<F>;
const result = fn.apply(this, args);
mapLike.set(arg, result);
return result;
}
node = next as CacheNode<ReturnType<F>>;
}
return fn.apply(this, args);
};
return wrapped as F;
}This approach keeps objects GC-able (via WeakMap) and preserves identity semantics for reference arguments. It's more robust than stringifying.
5. Memoizing Async Functions (Caching in-flight Promises)
For async functions, naive caching of resolved values is fine, but you can improve concurrency by caching the in-flight Promise so that concurrent callers share a single pending request.
function memoizeAsync<F extends (...args: any[]) => Promise<any>>(fn: F) {
const cache = new Map<string, Promise<ReturnType<F>>>();
return function (this: any, ...args: Parameters<F>): ReturnType<F> {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key) as ReturnType<F>;
const p = Promise.resolve(fn.apply(this, args));
cache.set(key, p);
p.catch(() => cache.delete(key)); // optional: don't cache failures
return p as ReturnType<F>;
} as any;
}Key points:
- Cache the Promise itself to deduplicate concurrent calls.
- Optionally, remove failed promises from the cache to allow retries.
- If you want results only after resolution, you can store the resolved value separately.
For async iterators or generator-like behavior you might combine memoization patterns with async iteration. If your work touches streams or iterators, see Typing Async Iterators and Async Iterables in TypeScript — Practical Guide and Typing Iterators and Iterables in TypeScript for related patterns.
6. Handling Non-JSON Keys: Symbols, BigInt, Dates, RegExp
Some argument types are not safe with JSON.stringify.
- Symbols cannot be serialized and will be omitted.
- BigInt throws in JSON.stringify.
- Date and RegExp become strings and lose identity semantics.
Strategies:
- Use nested Map/WeakMap approach which supports any JS value as key (Maps accept objects, Symbols, BigInt, etc.).
- If you must stringify, implement a robust serializer that converts Symbols and BigInt deterministically — but this gets complex and brittle.
Example: treat Date specially by using date.valueOf(), but prefer Map/WeakMap to preserve object identity.
For guidance on Symbol and BigInt typings, see Typing Symbols as Object Keys in TypeScript — Comprehensive Guide and Typing BigInt in TypeScript: Practical Guide for Intermediate Developers.
7. Memory Management & Cache Eviction
An unbounded cache can cause memory leaks. Decide on eviction strategies:
- Use WeakMap for object keys so entries are GC-able when the key is gone.
- Implement an LRU (Least Recently Used) policy for Map-based caches that track recent access.
- Provide manual
clear()or TTL (time-to-live) options.
Example: a simple LRU wrapper using Map insertion order:
class LRUCache<K, V> {
private map = new Map<K, V>();
constructor(private maxSize = 1000) {}
get(key: K): V | undefined {
const v = this.map.get(key);
if (v === undefined) return undefined;
this.map.delete(key);
this.map.set(key, v);
return v;
}
set(key: K, value: V) {
if (this.map.size >= this.maxSize) {
const first = this.map.keys().next().value;
this.map.delete(first);
}
this.map.set(key, value);
}
}You can combine LRU with typed memoize wrappers to get predictable memory use.
8. Preserving Inference & Generics in Public APIs
When you expose a memoization utility as part of a library, preserving inference makes the wrapped function feel native. Avoid forcing consumers to pass generic types. Use conditional types and helper type aliases to infer automatically.
Example type that preserves this and args:
type MemoizeReturn<F extends (...args: any[]) => any> = (
this: ThisParameterType<F>,
...args: Parameters<OmitThisParameter<F>>
) => ReturnType<F>;
function typedMemoize<F extends (...args: any[]) => any>(fn: F): MemoizeReturn<F> {
// implement as before
return (function (this: any, ...args: any[]) {
return (fn as any).apply(this, args);
}) as MemoizeReturn<F>;
}This pattern keeps intellisense and type inference working for consumers without extra annotations.
9. Memoizing Methods in Classes
When memoizing methods, you might want per-instance caches (not shared across instances) or a static cache. Use decorators or explicit wrapping in the constructor.
Per-instance example:
class Expensive {
constructor() {
this.compute = memoizeWithIdentity(this.compute.bind(this));
}
compute(n: number) {
// expensive
return n * 2;
}
}If you need to attach caches to private members, see our piece on Typing Private and Protected Class Members in TypeScript for patterns to keep member types safe while storing caches.
10. Integrating with Third-Party Libraries
When building memoized adapters around third-party APIs, type the wrapper to match the upstream API so consumers get correct types. If the upstream library has complex or untyped signatures, consider writing declaration files or typed wrappers.
Our guide on Typing Third-Party Libraries with Complex APIs — A Practical Guide has patterns for gradual typing and runtime guards which are useful when memoizing untyped or poorly typed functions.
Advanced Techniques
- Conditional caching: Provide configuration to memoize only certain argument shapes or values. Use type predicates and overloads to restrict cache behavior.
- Stable serialization: If you must serialize complex structures, use a stable serializer that handles Symbols, BigInt, and predictable object key ordering — but prefer Maps/WeakMaps for correctness.
- Hybrid caching: Combine WeakMap identity caches for object keys and Map or string keys for primitives for maximum flexibility.
- Typed TTL and eviction policies: Expose typed options that describe TTLs as numbers or date instances. If you accept Date keys, ensure you type conversions; see Typing Built-in Objects in TypeScript: Math, Date, RegExp, and More for tips on Date/RegExp handling.
- Performance profiling: measure the overhead of memoization for extremely hot code paths — sometimes memoization costs (key creation, map lookups) outweigh savings unless computation is expensive.
Best Practices & Common Pitfalls
- Don’t memoize non-pure functions unless you clearly understand side effects.
- Avoid JSON.stringify for keys when functions accept objects, BigInt or Symbols.
- Be careful with floating time keys or non-deterministic inputs — they invalidate cache benefits.
- Choose WeakMap for object keys to avoid memory leaks; use Map + LRU for primitive-heavy workloads with eviction.
- Preserve
thistyping to prevent subtle runtime errors when memoizing methods. See our ThisParameterType guide for more. - When caching async functions, cache in-flight Promises and consider whether you should cache rejected results.
- Benchmark different strategies: nested Map vs single string key — in some cases string keys are faster for small arg sets, but nested maps are more correct and scale better with complex key types.
Troubleshooting tips:
- If cache misses are high, log serialized keys or argument shapes to ensure equality assumptions hold.
- If memory grows unexpectedly, profile heap and check that Maps are not holding references to short-lived objects — switch to WeakMap where possible.
- If inference is lost after wrapping, ensure you use the Parameters/ReturnType helpers and return a function cast to the inferred wrapper type (as shown earlier).
Real-World Applications
- Caching computed properties in UI libraries (avoiding repeated DOM calculations). If you create memoized utilities for DOM elements, ensure you type DOM node arguments carefully; see Typing DOM Elements and Events in TypeScript (Advanced) for guidance.
- Memoizing expensive pure functions (mathematical operations involving BigInt, Dates, RegExp parsing). For BigInt-heavy code, consult our BigInt typing guide: Typing BigInt in TypeScript.
- Network-layer request deduplication: memoizing fetch-like functions with in-flight Promise caching avoids duplicated HTTP requests. When using Node.js built-ins (http, streams), see Typing Node.js Built-in Modules in TypeScript for typing inbound responses.
- Creating typed caches for third-party library adapters — see the third-party libraries guide linked earlier.
Conclusion & Next Steps
Typed memoization in TypeScript provides both runtime performance benefits and compile-time safety. Start with a minimal typed wrapper, then evolve to nested Map/WeakMap designs for correctness with reference arguments. Preserve this typing, cache in-flight Promises for async functions, and adopt eviction strategies to avoid memory issues. For next steps, review tuple and rest parameter typing patterns and explore building typed decorators for memoization to reduce boilerplate.
Recommended follow-ups:
- Study advanced tuple patterns in Typing Function Parameters as Tuples in TypeScript
- Learn
thistyping deeply via Typing Functions That Modifythis(ThisParameterType, OmitThisParameter)
Enhanced FAQ
Q1: Should I always use Map/WeakMap instead of stringifying args? A1: Prefer Map/WeakMap for correctness. JSON.stringify is simple and sometimes faster for short, primitive-only argument lists, but it fails for BigInt, Symbol, functions, and loses object identity semantics. A mixed approach (WeakMap for objects, Map for primitives) offers robust behavior and avoids many pitfalls.
Q2: How do I preserve this and the original function type when memoizing class methods?
A2: Use ThisParameterType and OmitThisParameter to infer the this type and argument list, then return a function typed with that this signature. If you prefer decorators, implement a decorator factory that wraps the method and stores per-instance caches. See our guide on Typing Functions That Modify this (ThisParameterType, OmitThisParameter) for examples.
Q3: How can I memoize a function whose arguments include objects but should be equivalent by value, not identity? A3: If you want deep-equality semantics, you must canonicalize arguments (e.g., sort object keys and produce a stable string or use a canonical structural hash). This is expensive and error-prone. Consider whether identity-based caching (Map/WeakMap) fits your use case or whether you should implement a deterministic serializer with careful testing.
Q4: What about caching rejected Promises from async functions?
A4: Usually you should not cache rejected Promises permanently. Cache the in-flight Promise to deduplicate requests, but remove the entry on rejection so consumers can retry. Use p.catch(() => cache.delete(key)) to implement that behavior.
Q5: Can memoization break TypeScript inference for overloads or complex generics?
A5: It can if you wrap incorrectly. Use conditional helper types and cast the returned wrapper to the inferred signature (MemoizeReturn
Q6: How should I approach eviction policies for caches?
A6: Choose eviction based on workload: LRU is common for general-purpose caches. If keys are objects, prefer WeakMap to let GC handle eviction. For time-based caches, store expiration metadata (TTL) and periodically sweep, or lazily evict on access. Always expose a clear() to let consumers manage lifecycle.
Q7: Are there performance pitfalls to avoid? A7: Yes. Creating many temporary arrays/objects for keys can erase memoization benefits. Also, Map/WeakMap lookups have costs — test and benchmark. If the function is very cheap, memoization might add overhead instead of saving it. Use profiling to guide decisions.
Q8: How do I test memoized functions? A8: Write tests that assert:
- Results equal direct invocation for a variety of inputs.
- For object arguments, that identity-based caching behaves correctly (same object -> cached, equal but different object -> not cached unless using structural caching).
- For async functions, that concurrent calls share the same Promise and error handling is correct.
- For eviction, that the cache removes entries as expected.
Q9: Can I memoize generator functions or iterators? A9: Memoizing generators is trickier: generators produce sequences and often maintain internal state. You can memoize the produced sequence (e.g., turn it into an array) but not the generator itself without copying behavior. If you need to deal with typed iteration patterns, check out our articles on Typing Iterators and Iterables in TypeScript and Typing Async Iterators and Async Iterables in TypeScript — Practical Guide to understand best practices.
Q10: Any recommended libraries or utilities? A10: Many libraries provide memoization utilities (lodash.memoize etc.), but they may be untyped or insufficient for advanced needs. Often writing a small typed wrapper (as shown here) fits most use cases. If you use external libs, consider writing thin typed adapters and consult our guide on Typing Third-Party Libraries with Complex APIs — A Practical Guide to safely integrate them.
This guide covered designing typed memoization functions in TypeScript, from basic wrappers to advanced nested cache strategies, this preservation, async handling, and practical best practices. Experiment with the patterns shown and adapt eviction and serialization strategies to your application needs. For deeper background on tuple typing, this utilities, and related TypeScript topics referenced throughout this article, follow the linked guides.
