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:
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
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:
{ "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 TypedArrays, 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/Errorthrow. 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'spath+value.Temporalneeds aTemporalimplementation onglobalThisatdeserializetime (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:
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; see the comparison 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.