Implement memoize
Memoize wraps a function with a cache keyed by its arguments, so a repeated call returns the stored result instead of recomputing. Use a Map, build a key that includes each argument's type, and check membership with has() so a cached undefined still counts as a hit.
The question
It looks like a two-line question and then the follow-ups arrive:
- What if the function returns
undefined? - Should
f(2)andf("2")share a cache entry? - What about object arguments, and when does the cache get released?
The solution
The cache is created inside memoize, so each memoized function gets its own and two
of them can never interfere.
function memoize(func) {
// A Map per memoized function, so two of them never share a cache.
const cache = new Map();
return function (...args) {
// The type is part of the key, so 2 and "2" cannot collide.
const key = JSON.stringify(args.map((arg) => [typeof arg, arg]));
// has(), not a truthiness check: a cached undefined is still a cache hit.
if (cache.has(key)) return cache.get(key);
const result = func.apply(this, args);
cache.set(key, result);
return result;
};
}
How it works
The type is part of the key. Mapping each argument to [typeof arg, arg] before
stringifying keeps the number 2 and the string "2" apart. Use
cache.has(key) rather than a truthiness check, or a function that legitimately
returns undefined gets recomputed forever. The cache is created inside
memoize, so two memoized functions can’t interfere, and
func.apply(this, args) keeps the receiver so memoizing a method still works.
The tests it has to pass
This is the part most explanations leave out. "It works" is not a claim you can check by reading. These are the 6 cases Practice Pad runs against your solution, and the implementation above passes all of them.
✓ reuses the first result for a repeated argument
let calls = 0;
const square = memoize((value) => {
calls += 1;
return value * value;
});
expectEqual([square(4), square(4), calls], [16, 16, 1]);
✓ reuses the result for a repeated string argument
let calls = 0;
const shout = memoize((value) => {
calls += 1;
return value.toUpperCase();
});
expectEqual([shout("hi"), shout("hi"), shout("bye"), calls], ["HI", "HI", "BYE", 2]);
✓ treats the number 2 and the string "2" as separate arguments
let calls = 0;
const describe = memoize((value) => {
calls += 1;
return typeof value + ":" + String(value);
});
expectEqual([describe(2), describe("2"), calls], ["number:2", "string:2", 2]);
✓ computes separately for different arguments of the same type
let calls = 0;
const double = memoize((value) => {
calls += 1;
return value * 2;
});
expectEqual([double(1), double(2), double(1), calls], [2, 4, 2, 2]);
✓ keeps separate memoized functions independent
let leftCalls = 0;
let rightCalls = 0;
const left = memoize((value) => {
leftCalls += 1;
return value + "-L";
});
const right = memoize((value) => {
rightCalls += 1;
return value + "-R";
});
expectEqual([left("k"), right("k"), left("k"), leftCalls, rightCalls], ["k-L", "k-R", "k-L", 1, 1]);
✓ caches an undefined result instead of recomputing it
let calls = 0;
const alwaysUndefined = memoize(() => {
calls += 1;
return undefined;
});
alwaysUndefined(1);
alwaysUndefined(1);
expectEqual(calls, 1);
Edge cases to watch out for
Using a falsy check for the cache hit
if (cache[key]) return cache[key] misses every result that’s 0, "", false, null or undefined, so those are recomputed forever. Map.has() is the correct test.
Building the key with String(args) or join
String([2]) and String(["2"]) are both "2", so the two calls collide. So do f(1, 2) and f("1,2"). Include the type, or use a delimiter that can’t appear in the values.
Declaring the cache outside memoize
A module-level cache is shared by every memoized function, so two of them overwrite each other's entries. The first hard test creates two and checks they stay independent.
Assuming JSON.stringify handles every argument
It can’t key on object identity, throws on circular references, and drops functions. For object arguments a WeakMap keyed on the object itself is usually the better structure, and saying so is worth mentioning.
Follow up questions
How do you memoize a function that takes an object argument?
Key on identity with a WeakMap rather than serialising. Stringifying an object is slow, throws on circular references, and treats two structurally identical objects as the same call, which may or may not be what you want. A WeakMap also lets the entry be collected when the object is, which a string key can’t.
How should the cache key be built?
It has to distinguish arguments that stringify identically. Including typeof for each argument separates 2 from "2". For object arguments, identity is usually what you want, which means a WeakMap rather than a string key.
What’s the difference between memoization and caching?
Memoization is a specific kind of caching: the results of a pure function, keyed by its arguments, with no expiry. General caching may have TTLs, eviction policies, shared storage and invalidation. Memoizing an impure function is a bug, not an optimisation.
Does a memoized function leak memory?
A naive one does. The cache grows without bound for as long as the function is reachable, so memoizing over user input or unbounded arguments is a leak. Production implementations cap the size and evict, typically least-recently-used.
Practise this with the tests running
Practice Pad runs these 6 cases against your own solution, in a real editor, on your Mac. Twenty-four questions, and an optional AI panel that explains why a test failed.
Download for macOS Free. Requires macOS 12 or later on Apple Silicon.