Caveats & FAQs
Caveats
Internal Object Sharing (Aliasing)
For maximum performance, @opentf/obj-diff preserves internal object identity (sharing) during the patch() operation.
If your original object contains multiple paths pointing to the same object instance, patching one of those paths will affect all its aliases.
const shared = { x: 1 }; const a = { first: shared, second: shared }; const b = { first: { x: 1 }, second: { x: 2 } }; const d = diff(a, b); const res = patch(a, d); // res.first.x will be 2 because it shares the same instance as res.second console.log(res.first.x); // 2
Tip: If you require independent branches after patching, ensure your input objects do not share internal references that are expected to diverge.
Patched Output Shares Values With the Target
For the same performance reason, added and changed values are placed into the patched result by reference, not cloned. After patch(a, diff(a, b)), any object that was newly added or replaced is the same instance as the one inside b — mutating it later affects both.
const a = {}; const b = { user: { name: "Alice" } }; const res = patch(a, diff(a, b)); console.log(res.user === b.user); // true
Tip: If you need the result to be fully independent from
b, deep-clone it after patching.
Symbol Keys Are Ignored
Only own enumerable string keys are diffed. Properties keyed by Symbols are invisible to diff() — two objects differing only in symbol-keyed properties compare as equal.
Sets Are Compared by Insertion Order
Set elements are compared positionally, by insertion order. Two sets with the same members in a different order will report changes. This is intentional: insertion order is observable in JavaScript (iteration, serialization), so order changes are recorded as real changes.
Array Diffs Are Compact, but Moves Are Not Detected
Arrays are diffed with prefix/suffix trimming plus a bounded shortest-edit-script (Myers LCS) algorithm, so inserts and removals produce one INSERTED/REMOVED op per actual edit. Two cases still produce larger diffs:
Reorders: moving an element is expressed as a removal plus an insertion (or per-element changes), not a "move" op. Heavily shuffled lists produce diffs proportional to the shuffle.
Very different arrays: when the edit distance exceeds the internal cost cap, diffing falls back to index-by-index comparison to keep worst-case time and memory bounded.
Sparse arrays and arrays with own non-index properties always use the index-by-index comparison.
Strings Are Replaced Whole — No Text Diffing
Strings (and all primitives) are treated as atomic leaf values, compared with Object.is. When a string changes, the CHANGED op carries the entire new string as its value — there is no character-, word-, or line-level text diff.
diff({ bio: "The quick brown fox jumps over the lazy dog." }, { bio: "The quick brown fox leaps over the lazy dog." }); //=> [{ type: 2, path: ["bio"], value: "The quick brown fox leaps over the lazy dog." }] // one word changed, but the whole string is sent
This is by design: it keeps the engine small and fast, and the patch is a single assignment. If you edit long strings (documents, source code, logs) and need sub-string patches, run a dedicated text-diff at that leaf yourself — e.g. diff / diff-match-patch — and store its output under that path. obj-diff intentionally does not bundle one, since it would add a large dependency and a second patch format that most object diffs never need.
diffWith() Comparators Only See Objects
The custom comparator passed to diffWith() is invoked only when both values are objects. Primitive leaves (numbers, strings, etc.) are always compared with built-in Object.is semantics — so, for example, float-tolerance comparison of bare numbers cannot be intercepted.
Zero Sign Is Significant
Values are compared with Object.is (SameValue) semantics: 0 and -0 are reported as changed, and NaN equals NaN.
FAQs
1. Why is JSON Patch (RFC 6902) not supported?
The JSON Patch protocol is quite heavy and complex. We've optimized @opentf/obj-diff for performance and simplicity, which covers the vast majority of real-world use cases and securely handles Native JS objects (Maps, Sets, TypedArrays) which JSON patching simply cannot do.
2. What does an empty path path: [] mean?
An empty path denotes the Root of the object. It typically means the entire source was replaced by the target value (e.g., comparing an object to null).
3. Can I reverse (invert) a diff? Is there an unpatch()?
There is no unpatch(), and a diff is not invertible on its own. To keep diffs compact, an op stores only what's needed to apply it forward — DELETED and REMOVED carry no old value, and CHANGED carries only the new value. That's exactly the information an inverse would need, so it cannot be reconstructed from the diff alone.
To go from b back to a, compute the diff in the other direction:
const forward = diff(a, b); const backward = diff(b, a); // the reverse patch patch(a, forward); // => b patch(b, backward); // => a
For undo/redo, store diff(b, a) alongside the forward diff when you record the change — you then have both directions without keeping full snapshots of a and b.