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.

Easy to hard closures caching higher-order functions

The question

It looks like a two-line question and then the follow-ups arrive:

  1. What if the function returns undefined?
  2. Should f(2) and f("2") share a cache entry?
  3. 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.

Easy2 cases · basic caching

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]);
Medium2 cases · key distinctness

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]);
Hard2 cases · independence and undefined

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.

The unbounded-cache question. A plain memoize never evicts, so a long-lived one over many distinct arguments is a memory leak. Real implementations take a max size and evict least-recently-used. Volunteering that shows you’re thinking past the interview.

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.

See all 24 questions