Introducing a New JavaScript Library for Object Diffing and Patching

cover
1 May 2024

Welcome,

I am here to introduce the new JavaScript library for object diffing & patching.

@opentf/obj-diff

Live Demo with Visualization

Features

  • Deep Objects Diffing

  • Patching

  • Supports comparing custom object types (via diffWith)

  • TypeScript Support

  • Cross-Platform

Supported Types

  • Primitives
    • Undefined

    • Null

    • Number

    • String

    • Boolean

    • BigInt

  • Objects
    • Plain Objects, e.g. {}

    • Array

    • Date

    • Map

    • Set

Let's begin with some basics, and we will go through our examples to see the existing solutions and their issues.

Diffing

The diffing is the method used to compare objects, change detection, or object tracking.

The diff result is called patches.

It allows us to send fewer data to the backend to apply the patches.

Patching

The patching is the method used to re-create the modified object at the other end using the original object + Patches (the diff result).

Accuracy

Here, we are going to find out how accurate our library is, and we need similar, popular libraries to compare against them.

So, let's pick three popular libraries.

  1. Microdiff

  2. just-diff

  3. deep-object-diff

Let us first create a test file.

import { diff } from '@opentf/obj-diff';
import mDiff from "microdiff";
import { diff as jDiff } from "just-diff";
import { detailedDiff } from "deep-object-diff";

function run(a, b) {
  try {
    console.log("Micro Diff:");
    console.log(mDiff(a, b));
    console.log();
  } catch (error) {
    console.log("Error: ", error.message);
  }

  try {
    console.log("Just Diff:");
    console.log(jDiff(a, b));
    console.log();
  } catch (error) {
    console.log("Error: ", error.message);
  }

  try {
    console.log("deep-object-diff:");
    console.log(detailedDiff(a, b));
    console.log();
  } catch (error) {
    console.log("Error: ", error.message);
  }

  try {
    console.log("diff:");
    console.log(diff(a, b));
    console.log();
  } catch (error) {
    console.log("Error: ", error.message);
  }
}

Let's start our complete testing.

1. Simple with no difference between objects

run({}, {});
Micro Diff:
[]

Just Diff:
[]

deep-object-diff:
{
  added: {},
  deleted: {},
  updated: {},
}

diff:
[]

2. Simple with a single difference

run({ a: 1 }, { a: 2 });
Micro Diff:
[
  {
    path: [ "a" ],
    type: "CHANGE",
    value: 2,
    oldValue: 1,
  }
]

Just Diff:
[
  {
    op: "replace",
    path: [ "a" ],
    value: 2,
  }
]

deep-object-diff:
{
  added: {},
  deleted: {},
  updated: {
    a: 2,
  },
}

diff:
[
  {
    t: 2,
    p: [ "a" ],
    v: 2,
  }
]

3. Primitives

const a = [undefined, null, "string", 0, 1, 1n, true, false];
const b = [undefined, null, "string", 0, 1, 1n, true, false];
run(a, b);
Micro Diff:
[]

Just Diff:
[]

deep-object-diff:
{
  added: {},
  deleted: {},
  updated: {},
}

diff:
[]

All Working fine - no issues.

4. Number properties

const a = [NaN, Infinity, -Infinity];
const b = [NaN, Infinity, Infinity];
run(a, b);
Micro Diff:
[
  {
    path: [ 2 ],
    type: "CHANGE",
    value: Infinity,
    oldValue: -Infinity,
  }
]

Just Diff:
[
  {
    op: "replace",
    path: [ 0 ],
    value: NaN,
  }, {
    op: "replace",
    path: [ 2 ],
    value: Infinity,
  }
]

deep-object-diff:
{
  added: {},
  deleted: {},
  updated: {
    "0": NaN,
    "2": Infinity,
  },
}

diff:
[
  {
    t: 2,
    p: [ 2 ],
    v: Infinity,
  }
]

As you can see the just-diff & deep-object-diff incorrectly reporting the NaN value changed.

5. Simple, deep objects

const a = {
  a: {
    b: {
      c: [1, 2, 3]
    },
    d: null
  },
  text: 'Hello'
}

const b = {
  a: {
    b: {
      c: [1, 2, 3, 4, 5]
    },
  },
  text: 'Hello World'
}

run(a, b);
Micro Diff:
[
  {
    type: "CREATE",
    path: [ "a", "b", "c", 3 ],
    value: 4,
  }, {
    type: "CREATE",
    path: [ "a", "b", "c", 4 ],
    value: 5,
  }, {
    type: "REMOVE",
    path: [ "a", "d" ],
    oldValue: null,
  }, {
    path: [ "text" ],
    type: "CHANGE",
    value: "Hello World",
    oldValue: "Hello",
  }
]

Just Diff:
[
  {
    op: "remove",
    path: [ "a", "d" ],
  }, {
    op: "replace",
    path: [ "text" ],
    value: "Hello World",
  }, {
    op: "add",
    path: [ "a", "b", "c", 3 ],
    value: 4,
  }, {
    op: "add",
    path: [ "a", "b", "c", 4 ],
    value: 5,
  }
]

deep-object-diff:
{
  added: {
    a: {
      b: {
        c: { '3': 4, '4': 5 }
      },
    },
  },
  deleted: {
    a: {
      d: undefined,
    },
  },
  updated: {
    text: "Hello World",
  },
}

diff:
[
  {
    t: 1,
    p: [ "a", "b", "c", 3 ],
    v: 4,
  }, {
    t: 1,
    p: [ "a", "b", "c", 4 ],
    v: 5,
  }, {
    t: 0,
    p: [ "a", "d" ],
  }, {
    t: 2,
    p: [ "text" ],
    v: "Hello World",
  }
]

All Working fine - no issues.

6. Different object types

const a = {
  a: 1,
  b: 2,
  c: 3
}
const b = [1, 2, 3]

run(a, b);
Micro Diff:
[
  {
    type: "REMOVE",
    path: [ "a" ],
    oldValue: 1,
  }, {
    type: "REMOVE",
    path: [ "b" ],
    oldValue: 2,
  }, {
    type: "REMOVE",
    path: [ "c" ],
    oldValue: 3,
  }, {
    type: "CREATE",
    path: [ 0 ],
    value: 1,
  }, {
    type: "CREATE",
    path: [ 1 ],
    value: 2,
  }, {
    type: "CREATE",
    path: [ 2 ],
    value: 3,
  }
]

Just Diff:
[
  {
    op: "remove",
    path: [ "c" ],
  }, {
    op: "remove",
    path: [ "b" ],
  }, {
    op: "remove",
    path: [ "a" ],
  }, {
    op: "add",
    path: [ 0 ],
    value: 1,
  }, {
    op: "add",
    path: [ 1 ],
    value: 2,
  }, {
    op: "add",
    path: [ 2 ],
    value: 3,
  }
]

deep-object-diff:
{
  added: {
    "0": 1,
    "1": 2,
    "2": 3,
  },
  deleted: {
    a: undefined,
    b: undefined,
    c: undefined,
  },
  updated: {},
}

diff:
[
  {
    t: 2,
    p: [],
    v: [ 1, 2, 3 ],
  }
]

As you can see, the other libraries are reporting changes within the original object, but the actual object type was changed from plain object to Array.

Note: The empty path array { p: [] } in our diff result denotes the Root path.

7. Passing null as object

const a = {
  a: 1,
  b: 2,
  c: 3
}
const b = null

run(a, b);
Micro Diff:
Error:  newObj is not an Object. (evaluating 'key in newObj')

Just Diff:
Error:  both arguments must be objects or arrays

deep-object-diff:
{
  added: {},
  deleted: {},
  updated: null,
}

diff:
[
  {
    t: 2,
    p: [],
    v: null,
  }
]

8. Commonly used object types: Maps & Sets

const a = {
  obj: {
    m: new Map([['x', 0], ['y', 1]]),
    s: new Set([1, 2, 3])
  }
}

const b = {
  obj: {
    m: new Map([['x', 1], ['y', 0]]),
    s: new Set([1, 2, 3, 4, 5])
  }
}

run(a, b);
Micro Diff:
[]

Just Diff:
[]

deep-object-diff:
{
  added: {},
  deleted: {},
  updated: {},
}

diff:
[
  {
    t: 2,
    p: [ "obj", "m" ],
    v: Map(2) {
      "x": 1,
      "y": 0,
    },
  }, {
    t: 2,
    p: [ "obj", "s" ],
    v: Set(5) {
      1,
      2,
      3,
      4,
      5,
    },
  }
]

Performance

For performance evaluation, we have created a benchmark file with some objects in our repo.

The following table is the output.

┌───┬──────────────────┬─────────┬───────────────────┬────────┬─────────┐
│   │ Task Name        │ ops/sec │ Average Time (ns) │ Margin │ Samples │
├───┼──────────────────┼─────────┼───────────────────┼────────┼─────────┤
+ 0 │ diff             │ 252,694 │ 3957.346814404028 │ ±1.60% │ 25270   │
│ 1 │ microdiff        │ 218,441 │ 4577.892286564301 │ ±0.92% │ 21845   │
│ 2 │ deep-object-diff │ 121,385 │ 8238.188318642591 │ ±1.66% │ 12139   │
│ 3 │ just-diff        │ 105,292 │ 9497.35384615396  │ ±1.66% │ 10530   │
│ 4 │ deep-diff        │ 160,802 │ 6218.820533549017 │ ±1.59% │ 16081   │
└───┴──────────────────┴─────────┴───────────────────┴────────┴─────────┘

Conclusion

I hope I have justified the Fast & Accurate aspect of the library.

We have FAQs section; you can learn more there.

Please try to use it in your next project, and feel free to send us your feedback & issues you encounter via GitHub issues.

Please don't forget to check out our important Articles:

Happy coding! 🚀

🙏 Thanks for reading.