Skip to the code
cf.

Memoize with a key resolver and size limit

MediumFunctions

Write memoize(fn, options). It returns a function that caches fn's results by key. options is optional, and so are both of its fields, resolver and maxSize.

The key is resolver(...args), called with the same this and arguments as the memoized function. Without a resolver, the key is the first argument. Keys are compared the way a Map compares them.

On a miss, call fn with the caller's this and all the arguments, cache the result (even undefined), and return it. On a hit, return the cached result without calling fn.

maxSize, a positive whole number, is the most results the cache may hold. When a new key arrives and the cache is full, first drop the least recently used entry: the one whose key was last stored or hit longest ago. Without maxSize, the cache has no limit.

const getUser = memoize((id) => ({ id }), { maxSize: 2 });
getUser(1);
getUser(2);
getUser(1); // hit: 1 is now the most recently used
getUser(3); // the cache is full, so 2 is dropped
getUser(2); // miss: fn runs again
 
const area = memoize((w, h) => w * h, { resolver: (w, h) => w + "x" + h });
area(2, 3); // 6
area(2, 4); // 8: a different key

What the tests check

  1. without a resolver, the key is the first argument
    const fn = spy((a, b) => a + b);
    const add = memoize(fn);
    expect(add(1, 2)).toBe(3);
    expect(add(1, 99)).toBe(3);
    expect(fn).toHaveBeenCalledTimes(1);
    
  2. uses the resolver's result as the key
    const fn = spy((user) => user.name.toUpperCase());
    const shout = memoize(fn, { resolver: (user) => user.id });
    expect(shout({ id: 1, name: "ada" })).toBe("ADA");
    expect(shout({ id: 1, name: "someone else" })).toBe("ADA");
    expect(shout({ id: 2, name: "alan" })).toBe("ALAN");
    expect(fn).toHaveBeenCalledTimes(2);
    
  3. calls the resolver with the caller's this and all the arguments
    const resolver = spy((w, h) => w + "x" + h);
    const obj = { area: memoize((w, h) => w * h, { resolver }) };
    expect(obj.area(2, 3)).toBe(6);
    expect(resolver.calls[0].args).toEqual([2, 3]);
    expect(resolver.calls[0].this).toBe(obj);
    
  4. calls fn with the caller's this and all the arguments
    const fn = spy(function (a, b) {
      return this.base + a + b;
    });
    const obj = { base: 10, sum: memoize(fn) };
    expect(obj.sum(1, 2)).toBe(13);
    expect(fn.calls[0].this).toBe(obj);
    expect(fn.calls[0].args).toEqual([1, 2]);
    
  5. compares keys the way a Map does
    const fn = spy((x) => typeof x);
    const kind = memoize(fn);
    expect(kind(1)).toBe("number");
    expect(kind("1")).toBe("string");
    kind({});
    kind({});
    expect(fn).toHaveBeenCalledTimes(4);
    
  6. caches undefined results
    const fn = spy(() => undefined);
    const nothing = memoize(fn);
    nothing("a");
    nothing("a");
    expect(fn).toHaveBeenCalledTimes(1);
    
  7. holds at most maxSize results
    const fn = spy((n) => n * 10);
    const times10 = memoize(fn, { maxSize: 2 });
    times10(1);
    times10(2);
    times10(3);
    expect(fn).toHaveBeenCalledTimes(3);
    times10(2);
    times10(3);
    expect(fn).toHaveBeenCalledTimes(3);
    times10(1);
    expect(fn).toHaveBeenCalledTimes(4);
    
  8. drops the least recently used entry, not the oldest
    const fn = spy((n) => n * 10);
    const times10 = memoize(fn, { maxSize: 2 });
    times10(1);
    times10(2);
    times10(1);
    times10(3);
    expect(fn).toHaveBeenCalledTimes(3);
    times10(1);
    expect(fn).toHaveBeenCalledTimes(3);
    times10(2);
    expect(fn).toHaveBeenCalledTimes(4);
    
  9. has no limit without maxSize
    const fn = spy((n) => n);
    const same = memoize(fn);
    for (let i = 0; i < 1000; i++) same(i);
    for (let i = 0; i < 1000; i++) same(i);
    expect(fn).toHaveBeenCalledTimes(1000);
    

Learn more

solution.jsSaved in this browser

Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.

Ctrl + Enter runs the tests (⌘ + Enter on a Mac).