Skip to the code
cf.

Object diff

Write objectDiff(before, after). It compares two objects and returns { added, removed, changed }: three arrays of dotted paths, in any order.

A path is in added when after has it and before doesn't, in removed the other way round, and in changed when both have it with different values. A key counts as present when it's one of the object's own keys, even if its value is undefined.

When both sides hold the same kind of container at a path (two plain objects or two arrays), walk into them, using keys and indexes as the next parts of the path. Report an added or removed container once, at its own path, not each value inside it. If the kinds differ (an object becomes an array, a number, or null), that path is changed. Compare everything else with Object.is.

objectDiff(
  { name: "Ada", address: { city: "London", zip: "N1" }, tags: ["a", "b"] },
  { name: "Ada", address: { city: "Paris" }, tags: ["a", "c"], age: 36 },
);
// { added: ["age"], removed: ["address.zip"], changed: ["address.city", "tags.1"] }

What the tests check

  1. lists added, removed, and changed keys
    expect(objectDiff({ a: 1, b: 2 }, { b: 3, c: 4 })).toEqual({ added: ["c"], removed: ["a"], changed: ["b"] });
    
  2. returns empty lists for equal objects
    expect(objectDiff({ a: { b: [1, { c: 2 }] } }, { a: { b: [1, { c: 2 }] } })).toEqual({ added: [], removed: [], changed: [] });
    
  3. uses dotted paths for nested differences
    const diff = objectDiff(
      { user: { name: "Ada", address: { city: "London", zip: "N1" } } },
      { user: { name: "Ada", address: { city: "Paris" }, age: 36 } },
    );
    expect(diff).toEqual({ added: ["user.age"], removed: ["user.address.zip"], changed: ["user.address.city"] });
    
  4. walks into arrays by index
    expect(objectDiff({ tags: ["a", "b"] }, { tags: ["a", "c", "d"] })).toEqual({ added: ["tags.2"], removed: [], changed: ["tags.1"] });
    
  5. reports an added or removed branch once, at its top
    expect(objectDiff({ old: { x: 1, y: 2 } }, { fresh: { x: 1, list: [1] } })).toEqual({ added: ["fresh"], removed: ["old"], changed: [] });
    
  6. reports a change of kind at that path
    const diff = objectDiff({ a: { x: 1 }, b: [1], c: null, d: 1 }, { a: 5, b: { 0: 1 }, c: { x: 1 }, d: { x: 1 } });
    expect([...diff.changed].sort()).toEqual(["a", "b", "c", "d"]);
    expect(diff.added).toEqual([]);
    expect(diff.removed).toEqual([]);
    
  7. counts a key holding undefined as present
    expect(objectDiff({ a: undefined }, {})).toEqual({ added: [], removed: ["a"], changed: [] });
    expect(objectDiff({}, { a: undefined })).toEqual({ added: ["a"], removed: [], changed: [] });
    
  8. compares values with Object.is
    expect(objectDiff({ n: NaN, s: "1" }, { n: NaN, s: 1 })).toEqual({ added: [], removed: [], changed: ["s"] });
    

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