# obj-diff Full Documentation Generated from filesystem routes for https://obj-diff.opentechf.org. ## Getting Started URL: https://obj-diff.opentechf.org/docs Description: The complete guide to @opentf/obj-diff. { label: "Bun", content: }, { label: "pnpm", content: }, { label: "npm", content: }, { label: "Deno", content: } ]; # Introduction **@opentf/obj-diff** is the fast, accurate, and modern JavaScript Objects Diffing & Patching Library. ## πŸš€ Features - πŸ” **Deep Objects Diffing**: Detects changes at any depth. - βœ‚οΈ **Compact Array Diffs**: Shortest edit scripts (Myers LCS) β€” inserting one element into a 10k array yields 1 op, not 10,001. - 🩹 **Efficient Patching**: Apply diffs to recreate target objects. - πŸ› οΈ **Extensible**: Support for custom object types via `diffWith()`. - πŸ“¦ **Modern Ecosystem**: Built for Bun, Node.js, Deno, and Browser. - 🟦 **TypeScript Native**: Full type safety and autocompletion. - ⚑ **High Performance**: Optimized for speed and minimal memory footprint. ## πŸ“¦ Installation Install `@opentf/obj-diff` using your preferred package manager: ## πŸ›  Supported Types The library natively handles the following, at any depth: | Type | Note | | :--- | :--- | | Primitives | `undefined`, `null`, number, string, boolean, `BigInt` β€” compared with `Object.is`. | | Plain objects | Own enumerable string keys (symbol keys are ignored). | | `Array` | Compact diffs via prefix/suffix trimming + Myers LCS. | | `Date` | Compared by timestamp. | | `Map` | Diffed by entries; the Map key itself appears in the path. | | `Set` | Compared positionally, by insertion order. | | `TypedArray` | `Uint8Array`, `Float32Array`, etc. β€” compared element-wise. | | `ArrayBuffer` / `DataView` | Compared by their underlying bytes. | | `Temporal.*` | All 8 types β€” compared as immutable values (`Duration` structurally, since it has no `.equals()`). | | Class instances | Same prototype β†’ diffed by own enumerable properties; different classes β†’ reported as replaced. | ## API Reference URL: https://obj-diff.opentechf.org/docs/api Description: Complete reference for diff, patch, diffWith, the DiffResult type, and the op-type constants. # API Reference Everything is a named export from the package root: ```ts import { diff, patch, diffWith, // functions serialize, deserialize, // diff wire format stringify, parse, // general-purpose value codec DELETED, ADDED, CHANGED, INSERTED, REMOVED, // op-type constants } from "@opentf/obj-diff"; ``` | Export | Kind | Summary | | :--- | :--- | :--- | | [`diff`](#diff) | function | Deep-compare two values into a list of operations. | | [`patch`](#patch) | function | Apply a diff to reconstruct the target. | | [`diffWith`](#diffwith) | function | `diff` with a custom equality function. | | [`serialize`](#serialize) | function | Encode a diff to a self-describing JSON string. | | [`deserialize`](#deserialize) | function | Decode a serialized diff back into live typed values. | | [`stringify`](#stringify) | function | Encode **any** value to a type-safe JSON string. | | [`parse`](#parse) | function | Decode a `stringify` string back into live typed values. | | [`DiffResult`](#diffresult-type) | type | The shape of a single operation. | | [Op constants](#op-type-constants) | const | `DELETED`, `ADDED`, `CHANGED`, `INSERTED`, `REMOVED`. | --- ## `diff` ```ts function diff(a: unknown, b: unknown): DiffResult[] ``` Performs a deep comparison and returns the list of operations that transform `a` into `b`. An empty array means the two values are deeply equal. **Parameters** | Name | Type | Description | | :--- | :--- | :--- | | `a` | `unknown` | The source (original) value. | | `b` | `unknown` | The target value to compare against. | **Returns** β€” `DiffResult[]`, in application order. ```ts diff({ a: 1 }, { a: 2 }); //=> [{ type: 2, path: ["a"], value: 2 }] ``` ### Compact array diffs `diff` trims the common prefix and suffix of arrays and resolves the remaining middle with a bounded shortest-edit-script (Myers LCS) pass. Inserting one element at the front of a 10,000-element array produces **1 op**, not 10,001: ```ts diff([1, 2, 3], [0, 1, 2, 3]); //=> [{ type: 3, path: [0], value: 0 }] diff([1, 2, 3, 4], [1, 4]); //=> [{ type: 4, path: [1] }, { type: 4, path: [1] }] ``` `INSERTED` / `REMOVED` indexes are **application-time** (RFC 6902 style): apply the ops in order and each index is valid at the moment it is applied. When two arrays are too different (an internal edit-distance cap is exceeded), `diff` falls back to index-by-index comparison to bound worst-case time and memory. --- ## `patch` ```ts function patch(obj: unknown, patches: DiffResult[]): unknown ``` Applies a diff to `obj` and returns the reconstructed value. The round-trip always holds: `patch(a, diff(a, b))` is deep-equal to `b`. **Parameters** | Name | Type | Description | | :--- | :--- | :--- | | `obj` | `unknown` | The source value to apply the diff to. | | `patches` | `DiffResult[]` | A diff produced by `diff` or `diffWith`. | **Returns** β€” the patched value. ```ts const a = { a: 1 }; const b = { a: 2 }; patch(a, diff(a, b)); //=> { a: 2 } ``` Added and changed values are placed into the result **by reference**, not cloned. See [Caveats](/docs/caveats) for aliasing details. --- ## `diffWith` ```ts function diffWith( a: unknown, b: unknown, isChanged: (a: object, b: object) => boolean | undefined, ): DiffResult[] ``` Like [`diff`](#diff), but you supply a comparator to override equality for specific object types (e.g. MongoDB `ObjectId`, custom classes). **Parameters** | Name | Type | Description | | :--- | :--- | :--- | | `a` | `unknown` | The source value. | | `b` | `unknown` | The target value. | | `isChanged` | `(a, b) => boolean \| undefined` | Return `true`/`false` to force the result, or `undefined` to fall back to the built-in comparison. Invoked **only when both values are objects**. | **Returns** β€” `DiffResult[]`. ```ts import { diffWith } from "@opentf/obj-diff"; import { ObjectId } from "bson"; diffWith(recordA, recordB, (a, b) => { if (a instanceof ObjectId && b instanceof ObjectId) { return a.toString() !== b.toString(); } }); ``` --- ## `serialize` ```ts function serialize(diff: DiffResult[]): string ``` Encodes a diff into a self-describing JSON string that survives transport. A diff holds live values by reference, so `JSON.stringify` loses their types; `serialize` preserves them. See [Serialization](/docs/serialization) for the format and rationale. **Parameters** | Name | Type | Description | | :--- | :--- | :--- | | `diff` | `DiffResult[]` | A diff produced by `diff` or `diffWith`. | **Returns** β€” a JSON `string`. Preserves native types (`Date`, `Map`, `Set`, `TypedArray`, `URL`, …) plus circular references and shared identity among plain objects/arrays. Throws on symbols, functions, class-instance values, and cycles that run through a `Map`/`Set`/`Error`. ```ts const wire = serialize(diff(a, b)); ``` --- ## `deserialize` ```ts function deserialize(wire: string): DiffResult[] ``` Decodes a string from [`serialize`](#serialize) back into a diff whose values are live, correctly-typed objects β€” ready for [`patch`](#patch). **Parameters** | Name | Type | Description | | :--- | :--- | :--- | | `wire` | `string` | The output of `serialize`. | **Returns** β€” `DiffResult[]` with reconstructed values. ```ts patch(a, deserialize(wire)); // Date / Map / Set / … restored exactly ``` Deserializing a `Temporal` value requires a `Temporal` implementation on `globalThis` (native or a globally-installed polyfill). This applies to `parse` too. Encoding never needs one. --- ## `stringify` ```ts function stringify(value: unknown): string ``` The general-purpose counterpart to [`serialize`](#serialize): encodes **any** JavaScript value β€” not just a diff β€” into a self-describing, type-safe JSON string. Uses the same codec, so it preserves every supported native type (`Date`, `Map`, `Set`, `TypedArray`, `ArrayBuffer`, `DataView`, `Error`, `URL`, `BigInt`, `Temporal`, `NaN`/`Β±Infinity`/`-0`, `undefined`, …) plus circular references and shared identity among plain objects/arrays. A type-preserving drop-in for `JSON.stringify`. See the [superjson comparison](/docs/comparison#type-safe-serialization-vs-superjson). **Parameters** | Name | Type | Description | | :--- | :--- | :--- | | `value` | `unknown` | Any serializable JavaScript value. | **Returns** β€” a JSON `string`. Throws on symbols, functions, and class-instance values. ```ts const wire = stringify({ when: new Date(), tags: new Set([1, 2]), big: 42n }); ``` --- ## `parse` ```ts function parse(wire: string): T ``` Decodes a string from [`stringify`](#stringify) back into the original value with its types restored β€” the type-preserving counterpart to `JSON.parse`. **Parameters** | Name | Type | Description | | :--- | :--- | :--- | | `wire` | `string` | The output of `stringify`. | **Returns** β€” the reconstructed value (typed as `T`). ```ts const value = parse(wire); // Date / Set / BigInt restored ``` --- ## `DiffResult` type ```ts type DiffResult = { type: 0 | 1 | 2 | 3 | 4; // see the op-type table below path: Array; // path to the property; Map entries use the Map key itself value?: unknown; // the new value (present for ADDED, CHANGED, INSERTED) }; ``` ### Op-type constants | Value | Constant | Meaning | Applies to | | :---: | :--- | :--- | :--- | | `0` | `DELETED` | Key no longer exists (`delete obj.k` / `map.delete(k)`; on an array index it leaves a hole). | objects, Maps, arrays (sparse) | | `1` | `ADDED` | New key/value (on arrays: assign at index). | objects, Maps, Sets, arrays | | `2` | `CHANGED` | Value replaced at the path. | all | | `3` | `INSERTED` | Insert `value` at the index, shifting later elements right (`splice(i, 0, v)`). | arrays only | | `4` | `REMOVED` | Remove the element at the index, shifting later elements left (`splice(i, 1)`). | arrays only | ```ts import { DELETED, ADDED, CHANGED, INSERTED, REMOVED } from "@opentf/obj-diff"; diff(a, b).filter((op) => op.type === REMOVED); ``` An empty `path` (`[]`) denotes the **root** β€” the entire source was replaced by the target (e.g. comparing an object to `null`). ## Benchmarks URL: https://obj-diff.opentechf.org/docs/benchmarks Description: How @opentf/obj-diff performs on diff size, diff speed, and patch speed against modern JavaScript diffing libraries. label: row.label, obj: row.obj.toLocaleString(), micro: row.micro.toLocaleString(), })); export function OpCountTable() { return ( {opRows.map((row) => ( ))}
Edit on a {arraySize}-element arrayobj-diffmicrodiff
{row.label}{row.obj}{row.micro}
); } export function DiffSpeedTable() { return (
{diffRows.map((row) => ( ))}
Library {diffCols[0]} {diffCols[1]} {diffCols[2]} {diffCols[3]} {diffCols[4]}
{row.lib === PKG ? obj-diff : row.lib} {row.cells[0].text} {row.cells[1].text} {row.cells[2].text} {row.cells[3].text} {row.cells[4].text}
); } export function PatchTable() { return (
{patchRows.map((row) => ( ))}
Library (diff + patch) {patchCols[0]} {patchCols[1]} {patchCols[2]}
{row.lib === PKG ? obj-diff : row.lib} {row.cells[0].text} {row.cells[1].text} {row.cells[2].text}
); } # Benchmarks `@opentf/obj-diff` is built for two things other diffing libraries trade away: **compact diffs** and **correct handling of native JavaScript values**. Every number on this page is generated by [`benchmarks/collect.mjs`](https://github.com/Open-Tech-Foundation/obj-diff/tree/main/benchmarks/collect.mjs) and rendered straight from its output β€” nothing here is typed by hand. Regenerate with `tsr benchmarks:collect`. ## Diff size (fewer operations) Op counts are **deterministic** β€” they don't depend on the machine. This is where compact array diffs pay off: positional differs emit one operation per shifted element, while `obj-diff` emits one operation per actual edit. Smaller diffs mean smaller audit logs, smaller sync payloads, and smaller undo stacks β€” see [Use Cases](/docs/use-cases). ## Diff speed Average latency per call across modern diffing libraries; each column uses a single unit so the cells are directly comparable, and the fastest cell in each column is highlighted. On the `TypedArray` column `obj-diff` is roughly **{taFactor}Γ— faster than microdiff**, because it compares typed arrays natively instead of walking them element-by-element. Speed alone can mislead: a library that emits a single "the whole value changed" replacement (rather than an element-level diff) looks instant but produces a useless result. We discard any cell whose diff serializes to roughly the size of the data itself, showing **β€”** instead β€” that is why `recursive-diff` has no `TypedArray` time (it dumps the entire array, since it doesn't understand typed arrays). `obj-diff` computes a minimal, element-level edit script, which is why it is not always the lowest number on the plain-array row but stays honest. ## Patch speed End-to-end **diff + apply** round-trip, for libraries that can reconstruct the target from their diff. Some need a separate apply package β€” `just-diff` pairs with `just-diff-apply`, `fast-json-patch` applies its own RFC 6902 ops β€” whereas `@opentf/obj-diff` ships `diff` and `patch` together. ## Honest trade-offs Benchmarks are only useful when they include the cases you *don't* win: - **Small and deeply-nested objects**: `obj-diff` sits within a whisker of the fastest positional differ β€” nanoseconds apart, irrelevant at real-world scale. - **Large plain numeric arrays**: computing a *minimal* edit script (Myers LCS) costs more than a raw scan, so a positional differ can diff a big plain array faster β€” the payoff is the far smaller diff `obj-diff` produces. When the edit distance exceeds an internal cap, `obj-diff` falls back to index-by-index comparison to keep worst-case time and memory bounded. See [Caveats](/docs/caveats). The takeaway: reach for `obj-diff` when you care about **diff quality and correctness across real JS values**, not just raw nanoseconds on flat JSON. ## Caveats & FAQs URL: https://obj-diff.opentechf.org/docs/caveats Description: Things to know when working with @opentf/obj-diff. # 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. ```js 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 ``` 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. ```js const a = {}; const b = { user: { name: "Alice" } }; const res = patch(a, diff(a, b)); console.log(res.user === b.user); // true ``` 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 `Symbol`s 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. ```js 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`](https://github.com/kpdecker/jsdiff) / [diff-match-patch](https://github.com/google/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: ```js 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`. ## Comparison URL: https://obj-diff.opentechf.org/docs/comparison Description: How @opentf/obj-diff compares to microdiff, deep-diff-ts, deep-object-diff, just-diff, and fast-json-patch β€” plus type-safe serialization vs superjson. name: lib.name === PKG ? "obj-diff" : lib.name, isPkg: lib.name === PKG, cells: COLS.map((c) => ICON[lib.results[c]] ?? "β€”"), })); export function AccuracyTable() { return ( {accRows.map((row) => ( ))}
Library {HEADS[0]} {HEADS[1]} {HEADS[2]} {HEADS[3]} {HEADS[4]} {HEADS[5]}
{row.isPkg ? {row.name} : row.name} {row.cells[0]} {row.cells[1]} {row.cells[2]} {row.cells[3]} {row.cells[4]} {row.cells[5]}
); } export function SerdeAccuracyTable() { const [ours, sj] = serdeAcc.libraries; return (
{serdeAcc.types.map((t, i) => ( ))}
Valueobj-diffsuperjson
{t} {ICON[ours.cells[i]]} {ICON[sj.cells[i]]}
Round-trip score {ours.score} {sj.score}
); } export function SerdeSizeTable() { const [ours, sj] = serdeSize.rows; const cell = (v, best) => { if (v == null) return "β€”"; return v === best ? {v} B : `${v} B`; }; return (
{serdeSize.scenarios.map((s, i) => ( ))}
Valueobj-diffsuperjson
{s} {cell(ours.cells[i], serdeSize.best[i])} {cell(sj.cells[i], serdeSize.best[i])}
); } export function SerdeSpeedTable() { return (
{serdeSpeed.ops.map((o) => )} {serdeSpeed.rows.map((r) => ( {r.cells.map((c) => )} ))}
Library{o}
{r.lib === PKG ? obj-diff : r.lib}{c.best ? {c.text} : c.text}
); } # Comparison There are many JavaScript diffing libraries. Most were designed for **JSON** β€” plain objects, arrays, and primitives. `@opentf/obj-diff` is designed for **real JavaScript values**: `Map`, `Set`, `Date`, `RegExp`, `TypedArray`, `ArrayBuffer`, class instances, and circular references, with a `patch()` that replays a diff back onto the original. ## Correctness across native types The most important difference is not speed β€” it's whether a library **notices** a change at all. The table below is generated by the repository's accuracy benchmark ([`benchmarks/collect.mjs`](https://github.com/Open-Tech-Foundation/obj-diff/tree/main/benchmarks/collect.mjs)). ❌ means the library **silently reported no difference** for two values that were genuinely different β€” the most dangerous kind of wrong answer. `Map` and `Set` are the common blind spot: change a value in a `Map` and most libraries report `[]`. ## Capabilities | Capability | @opentf/obj-diff | Notes | | :--- | :--- | :--- | | Deep diffing at any depth | βœ… | | | `patch()` to reconstruct the target | βœ… | Round-trips: `patch(a, diff(a, b))` deep-equals `b`. | | Compact array diffs (Myers LCS) | βœ… | One op per real insert/remove β€” see [Benchmarks](/docs/benchmarks). | | Native `Map` / `Set` / `Date` / `TypedArray` | βœ… | No "convert to JSON first" step. | | Circular-reference safe | βœ… | | | Custom equality via `diffWith()` | βœ… | e.g. compare MongoDB `ObjectId`s by string. | | TypeScript-native | βœ… | Full types, no `@types` package needed. | | Move detection | ❌ | Reorders are expressed as remove + insert β€” see [Caveats](/docs/caveats). | | RFC 6902 (JSON Patch) wire format | ❌ | Intentional β€” see below. | ## Type-safe serialization vs superjson Diffing is only half the job β€” a diff usually has to *travel*. The same codec that powers [`serialize`/`deserialize`](/docs/serialization) (for diffs) is exposed as `stringify`/`parse` for **any** value β€” the same job as [superjson](https://github.com/blitz-js/superjson): keep real JavaScript types alive across a JSON boundary. The tables below run `stringify`/`parse` against `superjson` v{serde.meta.superjson} on identical values, generated by [`benchmarks/serialization.mjs`](https://github.com/Open-Tech-Foundation/obj-diff/tree/main/benchmarks/serialization.mjs). ### Type fidelity Round-trip a value, then check it comes back as the **same type with the same data**. ❌ is a silent loss (the value survives but wrong); πŸ’₯ throws. obj-diff restores every native type. superjson degrades `Float64Array` specials (`NaN`/`-0`/`∞` β†’ `0`), turns `ArrayBuffer`/`DataView` into plain objects, unwraps boxed primitives, and drops an `Error`'s own props β€” **without throwing**. Only `BigInt64Array` errors outright; the rest return a wrong value that flows straight into your app. ### Wire size Comparable overall: obj-diff is smaller for leaf and native-heavy values (no `{json,meta}` envelope), while superjson's single shared `meta` table wins when many special values cluster in one object. ### Speed obj-diff is consistently faster β€” superjson walks each value twice (once for `json`, once to record `meta` paths), where obj-diff encodes in a single pass. Absolute times are machine-dependent; the gap is the signal. ## When to choose something else `obj-diff` is not trying to be every diffing tool. Reach for an alternative when: - **You need the interoperable RFC 6902 JSON Patch format** (to send patches to a non-JS service, or apply them with a standard library) β†’ use [`fast-json-patch`](https://github.com/Starcounter-Jack/JSON-Patch) or `rfc6902`. `obj-diff` uses its own compact, JS-native op format instead; see the [FAQ](/docs/caveats#faqs). - **Your arrays are large and frequently reordered** and you need true *move* operations β†’ a move-detecting differ will produce smaller diffs for shuffles. `obj-diff` treats a move as a remove plus an insert. - **You only ever touch flat JSON and want the absolute lowest nanosecond count** on tiny objects β†’ a minimal positional differ like `microdiff` shaves a few hundred nanoseconds. For anything with native types, collections, or where diff size matters, `obj-diff` wins on the axes that count. ## Guide URL: https://obj-diff.opentechf.org/docs/guide Description: A practical walkthrough of diffing, reading results, patching, and custom equality with @opentf/obj-diff. # Guide This guide walks through the core workflow of `@opentf/obj-diff`: compute a diff, read it, apply it, and customize it. Every snippet assumes: ```js import { diff, patch, diffWith } from "@opentf/obj-diff"; ``` ## 1. Compute a diff `diff(a, b)` returns an array of operations that turn `a` into `b`. Each operation has a `type`, a `path`, and (for additions and changes) a `value`. ```js const a = { a: 1, b: 2 }; const b = { a: 2, c: 3 }; diff(a, b); /* [ { type: 2, path: ["a"], value: 2 }, // CHANGED { type: 0, path: ["b"] }, // DELETED { type: 1, path: ["c"], value: 3 }, // ADDED ] */ ``` The `path` is an array you can walk or join. It points at exactly what changed β€” see the [API Reference](/docs/api) for the full op-type table. ## 2. Read the result Because the result is plain data, you can filter and inspect it however you like: ```js const changes = diff(a, b); changes.length; // how many operations changes.filter((c) => c.type === 0); // just the deletions changes.map((c) => c.path.join(".")); // ["a", "b", "c"] ``` This is what makes the diff useful as an audit log, a sync payload, or a dirty-field marker β€” see [Use Cases](/docs/use-cases). ## 3. Apply a diff (patch) `patch(obj, changes)` replays a diff onto an object and returns the result. The round-trip always holds β€” patching `a` with `diff(a, b)` reproduces `b`: ```js const a = { foo: { bar: [1, 2] } }; const b = { foo: { bar: [1] } }; const changes = diff(a, b); const result = patch(a, changes); // deep-equal to b ``` ## 4. Work with native collections Unlike JSON-only differs, `obj-diff` compares `Map`, `Set`, `Date`, `TypedArray`, `Temporal`, and more directly β€” no conversion step. ```js // Set β€” compared positionally, by insertion order diff(new Set([1, 2]), new Set([2, 3])); //=> [ { type: 2, path: [0], value: 2 }, { type: 2, path: [1], value: 3 } ] // TypedArray β€” compared element-wise, and fast diff(new Uint8Array([1, 2, 3]), new Uint8Array([1, 4, 3])); //=> [ { type: 2, path: [1], value: 4 } ] // Temporal β€” compared as immutable values diff({ d: Temporal.PlainDate.from("2020-01-01") }, { d: Temporal.PlainDate.from("2021-06-15") }); //=> [ { type: 2, path: ["d"], value: } ] ``` A change replaces the whole value rather than diffing its fields. `Duration` is compared structurally (by its canonical string), because two equal-length durations expressed differently (`PT2H` vs `PT120M`) are genuinely different values. ## 5. Compact array diffs Array edits produce the shortest edit script β€” one operation per real insert or remove, not one per shifted element: ```js diff([1, 2, 3], [0, 1, 2, 3]); //=> [{ type: 3, path: [0], value: 0 }] // INSERTED at index 0 diff([1, 2, 3, 4], [1, 4]); //=> [{ type: 4, path: [1] }, { type: 4, path: [1] }] // REMOVED (application-time indexes) ``` See the [Benchmarks](/docs/benchmarks) for how this compares to positional differs. ## 6. Circular references are safe Recursive structures are compared without infinite loops: ```js const a = { id: 1 }; a.self = a; const b = { id: 2 }; b.self = b; diff(a, b); //=> [{ type: 2, path: ["id"], value: 2 }] ``` ## 7. Custom equality When two values should be treated as equal by your own rule (for example MongoDB `ObjectId`s), pass a comparator to `diffWith`: ```js import { ObjectId } from "bson"; diffWith(recordA, recordB, (x, y) => { if (x instanceof ObjectId && y instanceof ObjectId) { return x.toString() !== y.toString(); } }); ``` `diffWith` calls your comparator only when **both** values are objects. For primitive pairs it falls back to built-in equality, so returning `undefined` (as above, for non-`ObjectId`s) lets the default behavior take over. ## 8. Send a diff over the wire To move a diff between processes β€” client β†’ server, a Web Worker, storage β€” `JSON.stringify` isn't enough (it turns `Date`s into strings and `Map`/`Set`s into `{}`). Use `serialize` / `deserialize`, which preserve every type: ```js import { serialize, deserialize } from "@opentf/obj-diff"; const wire = serialize(diff(a, b)); // a type-safe JSON string patch(a, deserialize(wire)); // Date / Map / Set / … restored exactly ``` See [Serialization](/docs/serialization) for the format and when to use it. ## Next steps - [Serialization](/docs/serialization) β€” move a diff across a boundary with the correct types. - [API Reference](/docs/api) β€” every function, type, and constant. - [Caveats & FAQs](/docs/caveats) β€” aliasing, symbol keys, move detection, and more. - [Use Cases](/docs/use-cases) β€” where a structural diff pays off. ## Serialization URL: https://obj-diff.opentechf.org/docs/serialization Description: Send a diff across a process or network boundary with serialize / deserialize, and patch it back into the exact same types. # Serialization `diff()` and `patch()` are **in-process**: a diff holds the **real** values by reference, and `patch()` places them straight back. That is fast and lossless β€” but it only works when both sides run in the **same** JavaScript runtime. The moment a diff has to *travel* β€” to a server, a database, a Web Worker, another tab β€” you reach for `JSON.stringify`, and it quietly corrupts the special types: ```js JSON.stringify(diff({}, { at: new Date(), tags: new Set([1, 2]) })); // at -> "2026-07-15T00:00:00.000Z" (a string, not a Date) // tags -> {} (empty β€” the Set's contents are gone) // a BigInt value would throw outright ``` `serialize` / `deserialize` are the fix: a **self-describing wire format** that survives JSON and rebuilds the exact types on the other side. ## Usage ```js import { diff, patch, serialize, deserialize } from "@opentf/obj-diff"; // ── client ────────────────────────────── const wire = serialize(diff(a, b)); // a plain JSON string, safe to send send(wire); // ── server ────────────────────────────── const patched = patch(a, deserialize(wire)); // Date / Map / Set / … restored exactly ``` The round-trip holds: `patch(a, deserialize(serialize(diff(a, b))))` reconstructs `b` with the correct types. ## The format `value` and `path` stay **readable JSON**. Each special value is replaced by a lightweight reference token `"@n"`, and the real types are collected in a per-op `$refs` table: ```json { "type": 1, "path": ["user"], "value": { "id": 1, "name": "John Doe", "lastSeen": "@1", "roles": "@2", "preferences": "@3" }, "$refs": { "1": { "_t": "Date", "_v": "2026-07-15T00:00:00.000Z" }, "2": { "_t": "Set", "_v": ["admin", "editor"] }, "3": { "_t": "Map", "_v": [["theme", "dark"], ["notifications", true]] } } } ``` - `@n` β€” a reference token standing in for a special value. - `$refs` β€” the lookup table, keyed by token number. - `_t` β€” the type; `_v` β€” its serialized payload. Because the type info lives in `$refs` (never inline), a user object that happens to have `_t`/`_v` keys is just data and passes through untouched. Real strings shaped like `"@1"` are escaped automatically. ## What's covered `Date`, `RegExp`, `Map`, `Set`, all `TypedArray`s, `ArrayBuffer`, `DataView`, `Error` (and standard subclasses), `URL`, boxed primitives, `BigInt`, `NaN` / `Β±Infinity` / `-0`, `undefined`, and every `Temporal` type β€” **nested to any depth**, including `Map`/`Set` values and object keys that appear in a `path`. **Circular references and shared identity** survive too, for plain objects and arrays. A plain container reachable by more than one edge within an op (a cycle like `obj.self = obj`, mutually linked nodes, or the same object used in two places) is hoisted into `$refs` as an `obj`/`arr` entry and referenced by token, so it rebuilds with the **same identity** on the far side. Containers used only once stay inline as readable JSON, so the common case is unchanged. ## Limits (by design) - **Symbols, functions, and class instances throw.** They can't be reconstructed from JSON. (A plain-object or supported type at that path is fine β€” only an unsupported *value* throws.) - **Cycles through a `Map` / `Set` / `Error` throw.** Cycles among plain objects and arrays are supported (above); a cycle that passes through a special collection is not. - **Identity is resolved per op.** An object shared across two *separate* diff ops is rebuilt as two equal copies (each op is self-contained). This never affects `patch()` correctness; identity is preserved within a single op's `path` + `value`. - **`Temporal` needs a `Temporal` implementation on `globalThis`** at `deserialize` time (native, or a polyfill you install globally). Encoding never needs one. ## Serializing any value: `stringify` / `parse` `serialize`/`deserialize` are shaped for a **diff** (`DiffResult[]`). When you need the same type-safe codec for **any** value, reach for `stringify`/`parse` β€” a drop-in, type-preserving `JSON.stringify`/`JSON.parse`: ```js import { stringify, parse } from "@opentf/obj-diff"; const wire = stringify({ when: new Date(), tags: new Set(["a", "b"]), prefs: new Map([["theme", "dark"]]), big: 42n, }); const value = parse(wire); // Date / Set / Map / BigInt all restored ``` It shares the exact codec above, so the coverage is identical β€” every native type, plus circular references and shared identity. Symbols, functions, and class instances throw. This is the same job as [superjson](https://github.com/blitz-js/superjson); see the [comparison](/docs/comparison#type-safe-serialization-vs-superjson) for a fidelity, size, and speed breakdown. `serialize` / `deserialize` for a **diff** (`DiffResult[]`); `stringify` / `parse` for **any other value**. Same codec and the same guarantees β€” they differ only in the shape they expect. ## When *not* to serialize If your `diff()` and `patch()` run in the **same** process β€” dirty-field tracking, undo/redo, "compute then patch" β€” skip serialization entirely. The live diff is faster (no encoding), preserves object **references** across the whole diff (aliasing/shared structure survive globally), and can even carry values the wire format can't (class instances, symbols). Reach for `serialize` **only at the boundary** where a diff has to leave the runtime. ## Use Cases URL: https://obj-diff.opentechf.org/docs/use-cases Description: Where @opentf/obj-diff shines β€” practical patterns for diffing and patching in real applications. # Use Cases `@opentf/obj-diff` answers one question precisely: **given two JavaScript values, what exactly changed?** The result is structured data (`{ type, path, value }`), and `patch()` can replay it onto the original β€” so a diff is a complete, transportable description of a change. Because it works on real JS values β€” `Map`, `Set`, `Date`, `TypedArray`, `ArrayBuffer`, class instances, circular references β€” it fits codebases where "convert to JSON first" isn't an option. ## 1. Audit Logging & Change Tracking Store `diff(before, after)` instead of two full snapshots when a record is edited. You get a compact "what changed" log β€” who changed `user.address.city`, and to what β€” the backbone of audit trails, admin activity feeds, and compliance logging. ```js const before = { name: "Alice", address: { city: "Paris" } }; const after = { name: "Alice", address: { city: "Berlin" } }; auditLog.push({ user: currentUser, at: Date.now(), changes: diff(before, after), // [{ type: 2, path: ["address", "city"], value: "Berlin" }] }); ``` ## 2. Undo / Redo & Version History Keep forward **and reverse** diffs rather than deep copies of app state. A diff is not invertible on its own, so calculate both while you still have both versions. Memory stays proportional to the **size of the change**, not the size of the state. ```js history.push({ redo: diff(prevState, nextState), undo: diff(nextState, prevState), }); // Redo and undo: replay the matching stored diff state = patch(state, history[pointer].redo); state = patch(state, history[pointer].undo); ``` ## 3. Network Sync With Minimal Payloads Send only the diff and `patch()` it on the other side β€” collaborative apps, live dashboards, multiplayer game state, offline-first apps reconciling on reconnect. Use `serialize` / `deserialize` at the boundary so native JS values survive transport. ```js import { deserialize, diff, patch, serialize } from "@opentf/obj-diff"; // Sender socket.send(serialize(diff(lastSynced, current))); // Receiver socket.onmessage = ({ data }) => { doc = patch(doc, deserialize(data)); }; ``` ## 4. Dirty Checking in Forms & Editors Compare the working copy against the loaded original to drive a Save button, per-field "unsaved changes" markers (the `path` tells you which input to highlight), or the minimal PATCH payload for your API. ```js const changes = diff(original, draft); saveButton.disabled = changes.length === 0; for (const c of changes) markFieldDirty(c.path); ``` ## 5. Testing & Debugging When a deep-equality assertion fails, a diff pinpoints *where* β€” far faster to read than two large object dumps. It also catches unintended mutations: diff a value against an earlier clone and assert the result is empty. ```js const snapshot = clone(state); runSuspectCode(state); expect(diff(snapshot, state)).toEqual([]); // state must not be mutated ``` ## 6. Change-Driven Side Effects Diff the previous and next config/state and react only to what actually changed β€” re-render only affected components, re-run only affected jobs, invalidate only affected cache keys, or trigger webhooks with a precise change payload. ```js for (const c of diff(prevConfig, nextConfig)) { if (c.path[0] === "theme") rerenderTheme(); if (c.path[0] === "db") reconnectDatabase(); } ``` ## 7. Optimistic UI & Partial Updates Apply a patch locally and roll back if the server rejects it (store the reverse diff before applying), or compute a MongoDB-style partial update from an edited document instead of writing the whole record. ```js const changes = diff(saved, edited); const $set = {}; for (const c of changes) { if (c.type !== 0) $set[c.path.join(".")] = c.value; } await collection.updateOne({ _id }, { $set }); ``` ## When to Reach for Something Else - You need the interoperable **RFC 6902 JSON Patch** wire format β†’ `fast-json-patch` (strict JSON only). - Your arrays are large and frequently **reordered** β€” diffs here are positional (no move detection), so list reorders produce larger diffs than LCS-based tools. See [Caveats](/docs/caveats) for details.