Skip to the code
cf.

Memoize object arguments with a WeakMap

MediumFunctions

A cache that holds the objects it was called with keeps them in memory for as long as the cache lives, even when nothing else uses them. Write weakMapMemoize(fn), a memoize that avoids that. It returns a function that calls fn with the same arguments and returns its result, and remembers the result: a later call with the same arguments returns it without calling fn again.

Two calls count as the same when they have the same number of arguments and each argument matches the one in the same position. An object or a function matches only itself: a different object with the same contents doesn't match, and changing an object's contents doesn't stop it matching.

Other values match the way Map keys do: 1 and "1" are different, and NaN matches NaN. null and undefined count as these other values, not as objects.

The cache must not keep object or function arguments alive. Store each one only as a key of a WeakMap, never in a Map, a Set, an array, or a property, so that once nothing else references it, it and the results cached for it can be garbage-collected.

Results of undefined are remembered too. Each memoized function has its own cache.

const cartTotal = weakMapMemoize((cart, currency) => {
  console.log("computing");
  return cart.items.reduce((sum, item) => sum + item.price, 0) + " " + currency;
});
const cart = { items: [{ price: 5 }, { price: 7 }] };
cartTotal(cart, "EUR"); // logs "computing", returns "12 EUR"
cartTotal(cart, "EUR"); // returns "12 EUR", logs nothing
cartTotal(cart, "USD"); // logs "computing": a different currency
cartTotal({ items: [{ price: 5 }, { price: 7 }] }, "EUR"); // logs "computing": a different object

What the tests check

  1. calls fn once for the same object
    const fn = spy((user) => user.name.toUpperCase());
    const shout = weakMapMemoize(fn);
    const ada = { name: "ada" };
    expect(shout(ada)).toBe("ADA");
    expect(shout(ada)).toBe("ADA");
    expect(fn).toHaveBeenCalledTimes(1);
    
  2. compares objects by identity, not by contents
    const fn = spy((user) => user.name);
    const getName = weakMapMemoize(fn);
    const ada = { name: "Ada" };
    getName(ada);
    getName({ name: "Ada" });
    expect(fn).toHaveBeenCalledTimes(2);
    ada.name = "Grace";
    expect(getName(ada)).toBe("Ada");
    expect(fn).toHaveBeenCalledTimes(2);
    
  3. uses every argument, objects and other values mixed
    const fn = spy((list, status) => list.filter((item) => item.status === status));
    const select = weakMapMemoize(fn);
    const todos = [{ status: "done" }, { status: "open" }];
    const done = select(todos, "done");
    expect(select(todos, "done")).toBe(done);
    expect(select(todos, "open")).toEqual([{ status: "open" }]);
    expect(fn).toHaveBeenCalledTimes(2);
     
    const pair = spy((key, value) => [key, value]);
    const memoPair = weakMapMemoize(pair);
    const a = {};
    const b = {};
    memoPair("key", a);
    memoPair("key", b);
    memoPair("key", a);
    expect(pair).toHaveBeenCalledTimes(2);
    
  4. compares other values the way a Map does
    const fn = spy((x) => typeof x);
    const kind = weakMapMemoize(fn);
    expect(kind(1)).toBe("number");
    expect(kind("1")).toBe("string");
    kind(NaN);
    kind(NaN);
    expect(fn).toHaveBeenCalledTimes(3);
    
  5. accepts null and undefined arguments
    const fn = spy((x) => String(x));
    const show = weakMapMemoize(fn);
    expect(show(null)).toBe("null");
    expect(show(null)).toBe("null");
    expect(show(undefined)).toBe("undefined");
    expect(show(undefined)).toBe("undefined");
    expect(fn).toHaveBeenCalledTimes(2);
    
  6. tells f(x) and f(x, undefined) apart
    const fn = spy((...args) => args.length);
    const count = weakMapMemoize(fn);
    const obj = {};
    expect(count(obj)).toBe(1);
    expect(count(obj, undefined)).toBe(2);
    expect(count()).toBe(0);
    expect(count(obj)).toBe(1);
    expect(fn).toHaveBeenCalledTimes(3);
    
  7. remembers undefined results
    const fn = spy(() => undefined);
    const nothing = weakMapMemoize(fn);
    const obj = {};
    nothing(obj);
    nothing(obj);
    expect(fn).toHaveBeenCalledTimes(1);
    
  8. each memoized function has its own cache
    const user = { name: "Ada" };
    const upper = weakMapMemoize((u) => u.name.toUpperCase());
    const lower = weakMapMemoize((u) => u.name.toLowerCase());
    expect(upper(user)).toBe("ADA");
    expect(lower(user)).toBe("ada");
    
  9. keeps object and function arguments only as WeakMap keys
    const weakKeys = [];
    const strongKeys = [];
    const weakSet = WeakMap.prototype.set;
    const mapSet = Map.prototype.set;
    const setAdd = Set.prototype.add;
    WeakMap.prototype.set = function (key, value) {
      weakKeys.push(key);
      return weakSet.call(this, key, value);
    };
    Map.prototype.set = function (key, value) {
      strongKeys.push(key);
      return mapSet.call(this, key, value);
    };
    Set.prototype.add = function (value) {
      strongKeys.push(value);
      return setAdd.call(this, value);
    };
    const user = { name: "Ada" };
    const format = (u) => u.name;
    let result;
    try {
      const render = weakMapMemoize((u, fmt, size) => fmt(u) + size);
      result = render(user, format, 2);
      render(user, format, 2);
    } finally {
      WeakMap.prototype.set = weakSet;
      Map.prototype.set = mapSet;
      Set.prototype.add = setAdd;
    }
    expect(result).toBe("Ada2");
    expect(weakKeys).toContain(user);
    expect(weakKeys).toContain(format);
    expect(strongKeys.filter((key) => key === user || key === format)).toEqual([]);
    

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).