Deterministic JSON state transitions with key-based array identity. A TypeScript JSON diff library that computes, applies, and reverts atomic changes using the JSON Atom wire format -- a JSON Patch alternative with stable array paths, built-in undo/redo for JSON, and language-agnostic state synchronization.
Zero dependencies. TypeScript-first. ESM + CommonJS. Trusted by thousands of developers (500K+ weekly npm downloads).
Most JSON diff libraries track array changes by position. Insert one element at the start and every path shifts:
Remove /items/0 ← was actually "Widget"
Add /items/0 ← now it's "NewItem"
Update /items/1 ← this used to be /items/0
...
This makes diffs fragile -- you can't store them, replay them reliably, or build audit logs on top of them. Reorder the array and every operation is wrong. This is the fundamental problem with index-based formats like JSON Patch (RFC 6902): paths like /items/0 are positional, so any insertion, deletion, or reorder invalidates every subsequent path.
json-diff-ts solves this with key-based identity. Array elements are matched by a stable key (id, sku, or any field), and paths use JSONPath filter expressions that survive insertions, deletions, and reordering:
import { diffAtom, applyAtom, revertAtom } from 'json-diff-ts';
const before = {
items: [
{ id: 1, name: 'Widget', price: 9.99 },
{ id: 2, name: 'Gadget', price: 24.99 },
],
};
const after = {
items: [
{ id: 2, name: 'Gadget', price: 24.99 }, // reordered
{ id: 1, name: 'Widget Pro', price: 14.99 }, // renamed + repriced
{ id: 3, name: 'Doohickey', price: 4.99 }, // added
],
};
const atom = diffAtom(before, after, { arrayIdentityKeys: { items: 'id' } });The atom tracks what changed, not where it moved:
{
"format": "json-atom",
"version": 1,
"operations": [
{ "op": "replace", "path": "$.items[?(@.id==1)].name", "value": "Widget Pro", "oldValue": "Widget" },
{ "op": "replace", "path": "$.items[?(@.id==1)].price", "value": 14.99, "oldValue": 9.99 },
{ "op": "add", "path": "$.items[?(@.id==3)]", "value": { "id": 3, "name": "Doohickey", "price": 4.99 } }
]
}Apply forward to get the new state, or revert to restore the original:
// Clone before applying — applyAtom mutates the input object
const updated = applyAtom(structuredClone(before), atom); // updated === after
const restored = revertAtom(structuredClone(updated), atom); // restored === beforeimport { diffAtom, applyAtom, revertAtom } from 'json-diff-ts';
const oldObj = {
items: [
{ id: 1, name: 'Widget', price: 9.99 },
{ id: 2, name: 'Gadget', price: 24.99 },
],
};
const newObj = {
items: [
{ id: 1, name: 'Widget Pro', price: 9.99 },
{ id: 2, name: 'Gadget', price: 24.99 },
{ id: 3, name: 'Doohickey', price: 4.99 },
],
};
// 1. Compute an atom between two JSON objects
const atom = diffAtom(oldObj, newObj, {
arrayIdentityKeys: { items: 'id' }, // match array elements by 'id' field
});
// atom.operations =>
// [
// { op: 'replace', path: '$.items[?(@.id==1)].name', value: 'Widget Pro', oldValue: 'Widget' },
// { op: 'add', path: '$.items[?(@.id==3)]', value: { id: 3, name: 'Doohickey', price: 4.99 } }
// ]
// 2. Apply the atom to produce the new state
const updated = applyAtom(structuredClone(oldObj), atom);
// 3. Revert the atom to restore the original state
const reverted = revertAtom(structuredClone(updated), atom);That's it. atom is a plain JSON object you can store in a database, send over HTTP, or consume in any language.
npm install json-diff-ts// ESM / TypeScript
import { diffAtom, applyAtom, revertAtom } from 'json-diff-ts';
// CommonJS
const { diffAtom, applyAtom, revertAtom } = require('json-diff-ts');JSON Atom is a specification for representing atomic changes to JSON documents. json-diff-ts is the originating implementation from which the spec was derived.
json-atom-format (specification)
├── json-diff-ts (TypeScript implementation) ← this package
└── json-atom-py (Python implementation)
The specification defines the wire format. Each language implementation produces and consumes compatible atoms.
An atom is a self-describing JSON document you can store, transmit, and consume in any language:
- Three operations --
add,remove,replace. Nothing else to learn. - JSONPath-based paths --
$.items[?(@.id==1)].nameidentifies elements by key, not index. - Reversible by default -- every
replaceandremoveincludesoldValuefor undo. - Self-identifying -- the
formatfield makes atoms discoverable without external context. - Extension-friendly -- unknown properties are preserved;
x_-prefixed properties are future-safe.
JSON Patch uses JSON Pointer paths like /items/0 that reference array elements by index. When an element is inserted at position 0, every subsequent path shifts -- /items/1 now points to what was /items/0. This makes stored patches unreliable for JSON change tracking, audit logs, or undo/redo across time.
JSON Atom uses JSONPath filter expressions like $.items[?(@.id==1)] that identify elements by a stable key. The path stays valid regardless of insertions, deletions, or reordering.
| JSON Atom | JSON Patch (RFC 6902) | |
|---|---|---|
| Path syntax | JSONPath ($.items[?(@.id==1)]) |
JSON Pointer (/items/0) |
| Array identity | Key-based -- survives reorder | Index-based -- breaks on insert/delete |
| Reversibility | Built-in oldValue |
Not supported |
| Self-describing | format field in envelope |
No envelope |
| Specification | json-atom-format | RFC 6902 |
const atom = diffAtom(
{ user: { name: 'Alice', role: 'viewer' } },
{ user: { name: 'Alice', role: 'admin' } }
);
// atom.operations → [{ op: 'replace', path: '$.user.role', value: 'admin', oldValue: 'viewer' }]Match array elements by identity key. Filter paths use canonical typed literals per the spec:
const atom = diffAtom(
{ users: [{ id: 1, role: 'viewer' }, { id: 2, role: 'editor' }] },
{ users: [{ id: 1, role: 'admin' }, { id: 2, role: 'editor' }] },
{ arrayIdentityKeys: { users: 'id' } }
);
// atom.operations → [{ op: 'replace', path: '$.users[?(@.id==1)].role', value: 'admin', oldValue: 'viewer' }]Omit oldValue fields when you don't need undo:
const atom = diffAtom(source, target, { reversible: false });Applies operations sequentially. Always use the return value (required for root-level replacements):
const result = applyAtom(structuredClone(source), atom);Computes the inverse and applies it. Requires oldValue on all replace and remove operations:
const original = revertAtom(structuredClone(target), atom);Returns a new atom that undoes the original (spec Section 9.2):
const inverse = invertAtom(atom);
// add ↔ remove, replace swaps value/oldValue, order reversedconst { valid, errors } = validateAtom(maybeAtom);| Function | Signature | Description |
|---|---|---|
diffAtom |
(oldObj, newObj, options?) => IJsonAtom |
Compute a canonical JSON Atom |
applyAtom |
(obj, atom) => any |
Apply an atom sequentially. Returns the result |
revertAtom |
(obj, atom) => any |
Revert a reversible atom |
invertAtom |
(atom) => IJsonAtom |
Compute the inverse atom |
validateAtom |
(atom) => { valid, errors } |
Structural validation |
toAtom |
(changeset, options?) => IJsonAtom |
Bridge: v4 changeset to JSON Atom |
fromAtom |
(atom) => IAtomicChange[] |
Bridge: JSON Atom to v4 atomic changes |
squashAtoms |
(source, atoms, options?) => IJsonAtom |
Compact multiple atoms into one net-effect atom |
atomMap |
(atom, fn) => IJsonAtom |
Transform each operation in an atom |
atomStamp |
(atom, extensions) => IJsonAtom |
Set extension properties on all operations |
atomGroupBy |
(atom, keyFn) => Record<string, IJsonAtom> |
Group operations into sub-atoms |
operationSpecDict |
(op) => IAtomOperation |
Strip extension properties from operation |
operationExtensions |
(op) => Record<string, any> |
Get extension properties from operation |
atomSpecDict |
(atom) => IJsonAtom |
Strip all extensions from atom |
atomExtensions |
(atom) => Record<string, any> |
Get envelope extensions from atom |
leafProperty |
(op) => string | null |
Terminal property name from operation path |
Extends the base Options interface:
interface AtomOptions extends Options {
reversible?: boolean; // Include oldValue for undo. Default: true
arrayIdentityKeys?: Record<string, string | FunctionKey>;
keysToSkip?: readonly string[];
}Transform, inspect, and compact atoms for workflow automation.
Combine a sequence of atoms into a single net-effect atom. Useful for compacting audit logs or collapsing undo history:
import { diffAtom, applyAtom, squashAtoms } from 'json-diff-ts';
const source = { name: 'Alice', role: 'viewer' };
const d1 = diffAtom(source, { name: 'Bob', role: 'viewer' });
const d2 = diffAtom({ name: 'Bob', role: 'viewer' }, { name: 'Bob', role: 'admin' });
const squashed = squashAtoms(source, [d1, d2]);
// squashed.operations => [
// { op: 'replace', path: '$.name', value: 'Bob', oldValue: 'Alice' },
// { op: 'replace', path: '$.role', value: 'admin', oldValue: 'viewer' }
// ]
// Verify: applying the squashed atom equals applying both sequentially
const result = applyAtom(structuredClone(source), squashed);
// result => { name: 'Bob', role: 'admin' }Options: reversible, arrayIdentityKeys, target (pre-computed final state), verifyTarget (default: true).
All transforms are immutable — they return new atoms without modifying the original:
import { diffAtom, atomMap, atomStamp, atomGroupBy } from 'json-diff-ts';
const atom = diffAtom(
{ name: 'Alice', age: 30, role: 'viewer' },
{ name: 'Bob', age: 31, status: 'active' }
);
// Stamp metadata onto every operation
const stamped = atomStamp(atom, { x_author: 'system', x_ts: Date.now() });
// Transform operations
const prefixed = atomMap(atom, (op) => ({
...op,
path: op.path.replace('$', '$.data'),
}));
// Group by operation type
const groups = atomGroupBy(atom, (op) => op.op);
// groups => { replace: IJsonAtom, add: IJsonAtom, remove: IJsonAtom }Separate spec-defined fields from extension properties:
import { operationSpecDict, operationExtensions, atomSpecDict } from 'json-diff-ts';
const op = { op: 'replace', path: '$.name', value: 'Bob', x_author: 'system' };
operationSpecDict(op); // { op: 'replace', path: '$.name', value: 'Bob' }
operationExtensions(op); // { x_author: 'system' }
// Strip all extensions from an atom
const clean = atomSpecDict(atom);Extract the terminal property name from an operation's path:
import { leafProperty } from 'json-diff-ts';
leafProperty({ op: 'replace', path: '$.user.name' }); // 'name'
leafProperty({ op: 'add', path: '$.items[?(@.id==1)]' }); // null (filter)
leafProperty({ op: 'replace', path: '$' }); // null (root)Serialize enriched comparison trees to plain objects or flat change lists.
import { compare, comparisonToDict, comparisonToFlatList } from 'json-diff-ts';
const result = compare(
{ name: 'Alice', age: 30, role: 'viewer' },
{ name: 'Bob', age: 30, status: 'active' }
);
// Recursive plain object
const dict = comparisonToDict(result);
// {
// type: 'CONTAINER',
// value: {
// name: { type: 'UPDATE', value: 'Bob', oldValue: 'Alice' },
// age: { type: 'UNCHANGED', value: 30 },
// role: { type: 'REMOVE', oldValue: 'viewer' },
// status: { type: 'ADD', value: 'active' }
// }
// }
// Flat list of leaf changes with paths
const flat = comparisonToFlatList(result);
// [
// { path: '$.name', type: 'UPDATE', value: 'Bob', oldValue: 'Alice' },
// { path: '$.role', type: 'REMOVE', oldValue: 'viewer' },
// { path: '$.status', type: 'ADD', value: 'active' }
// ]
// Include unchanged entries
const all = comparisonToFlatList(result, { includeUnchanged: true });Store every change to a document as a reversible atom. Each entry records who changed what, when, and can be replayed or reverted independently -- a complete JSON change tracking system:
import { diffAtom, applyAtom, revertAtom, IJsonAtom } from 'json-diff-ts';
interface AuditEntry {
timestamp: string;
userId: string;
atom: IJsonAtom;
}
const auditLog: AuditEntry[] = [];
let doc = {
title: 'Project Plan',
status: 'draft',
items: [
{ id: 1, task: 'Design', done: false },
{ id: 2, task: 'Build', done: false },
],
};
function updateDocument(newDoc: typeof doc, userId: string) {
const atom = diffAtom(doc, newDoc, {
arrayIdentityKeys: { items: 'id' },
});
if (atom.operations.length > 0) {
auditLog.push({ timestamp: new Date().toISOString(), userId, atom });
doc = applyAtom(structuredClone(doc), atom);
}
return doc;
}
// Revert the last change
function undo(): typeof doc {
const last = auditLog.pop();
if (!last) return doc;
doc = revertAtom(structuredClone(doc), last.atom);
return doc;
}
// Example usage:
updateDocument(
{ ...doc, status: 'active', items: [{ id: 1, task: 'Design', done: true }, ...doc.items.slice(1)] },
'alice'
);
// auditLog[0].atom.operations =>
// [
// { op: 'replace', path: '$.status', value: 'active', oldValue: 'draft' },
// { op: 'replace', path: '$.items[?(@.id==1)].done', value: true, oldValue: false }
// ]Because every atom is self-describing JSON, your audit log is queryable, storable in any database, and readable from any language.
Build undo/redo for any JSON state object. Atoms are small (only changed fields), reversible, and serializable:
import { diffAtom, applyAtom, revertAtom, IJsonAtom } from 'json-diff-ts';
class UndoManager<T extends object> {
private undoStack: IJsonAtom[] = [];
private redoStack: IJsonAtom[] = [];
constructor(private state: T) {}
apply(newState: T): T {
const atom = diffAtom(this.state, newState);
if (atom.operations.length === 0) return this.state;
this.undoStack.push(atom);
this.redoStack = [];
this.state = applyAtom(structuredClone(this.state), atom);
return this.state;
}
undo(): T {
const atom = this.undoStack.pop();
if (!atom) return this.state;
this.redoStack.push(atom);
this.state = revertAtom(structuredClone(this.state), atom);
return this.state;
}
redo(): T {
const atom = this.redoStack.pop();
if (!atom) return this.state;
this.undoStack.push(atom);
this.state = applyAtom(structuredClone(this.state), atom);
return this.state;
}
}Send only what changed between client and server. Atoms are compact -- a single field change in a 10KB document produces a few bytes of atom, making state synchronization efficient over the wire:
import { diffAtom, applyAtom, validateAtom } from 'json-diff-ts';
// Client side: compute and send atom
const atom = diffAtom(localState, updatedState, {
arrayIdentityKeys: { records: 'id' },
});
await fetch('/api/sync', {
method: 'POST',
body: JSON.stringify(atom),
});
// Server side: validate and apply
const result = validateAtom(req.body);
if (!result.valid) return res.status(400).json(result.errors);
// ⚠️ In production, sanitize paths/values to prevent prototype pollution
// (e.g. reject paths containing "__proto__" or "constructor")
currentState = applyAtom(structuredClone(currentState), req.body);Convert between the legacy internal format and JSON Atom:
import { diff, toAtom, fromAtom, unatomizeChangeset } from 'json-diff-ts';
// v4 changeset → JSON Atom
const changeset = diff(source, target, { arrayIdentityKeys: { items: 'id' } });
const atom = toAtom(changeset);
// JSON Atom → v4 atomic changes
const atoms = fromAtom(atom);
// v4 atomic changes → hierarchical changeset (if needed)
const cs = unatomizeChangeset(atoms);Note: toAtom is a best-effort bridge. Filter literals are always string-quoted (e.g., [?(@.id=='42')] instead of canonical [?(@.id==42)]). Use diffAtom() for fully canonical output.
All v4 APIs remain fully supported. Existing code continues to work without changes. For new projects, prefer the JSON Atom API above.
Generates a hierarchical changeset between two objects:
import { diff } from 'json-diff-ts';
const oldData = {
location: 'Tatooine',
characters: [
{ id: 'LUKE', name: 'Luke Skywalker', role: 'Farm Boy' },
{ id: 'LEIA', name: 'Princess Leia', role: 'Prisoner' }
],
};
const newData = {
location: 'Yavin Base',
characters: [
{ id: 'LUKE', name: 'Luke Skywalker', role: 'Pilot', rank: 'Commander' },
{ id: 'HAN', name: 'Han Solo', role: 'Smuggler' }
],
};
const changes = diff(oldData, newData, { arrayIdentityKeys: { characters: 'id' } });import { applyChangeset, revertChangeset } from 'json-diff-ts';
const updated = applyChangeset(structuredClone(oldData), changes);
const reverted = revertChangeset(structuredClone(newData), changes);Flatten a hierarchical changeset into atomic changes addressable by JSONPath, or reconstruct the hierarchy:
import { atomizeChangeset, unatomizeChangeset } from 'json-diff-ts';
const atoms = atomizeChangeset(changes);
// [
// { type: 'UPDATE', key: 'location', value: 'Yavin Base', oldValue: 'Tatooine',
// path: '$.location', valueType: 'String' },
// { type: 'ADD', key: 'rank', value: 'Commander',
// path: "$.characters[?(@.id=='LUKE')].rank", valueType: 'String' },
// ...
// ]
const restored = unatomizeChangeset(atoms.slice(0, 2));// Named key
diff(old, new, { arrayIdentityKeys: { characters: 'id' } });
// Function key
diff(old, new, {
arrayIdentityKeys: {
characters: (obj, shouldReturnKeyName) => (shouldReturnKeyName ? 'id' : obj.id)
}
});
// Regex path matching
const keys = new Map();
keys.set(/^characters/, 'id');
diff(old, new, { arrayIdentityKeys: keys });
// Value-based identity for primitive arrays
diff(old, new, { arrayIdentityKeys: { tags: '$value' } });diff(old, new, { keysToSkip: ['characters.metadata'] });diff(old, new, { treatTypeChangeAsReplace: false });| Function | Description |
|---|---|
diff(oldObj, newObj, options?) |
Compute hierarchical changeset |
applyChangeset(obj, changeset) |
Apply a changeset to an object |
revertChangeset(obj, changeset) |
Revert a changeset from an object |
atomizeChangeset(changeset) |
Flatten to atomic changes with JSONPath |
unatomizeChangeset(atoms) |
Reconstruct hierarchy from atomic changes |
| Function | Description |
|---|---|
compare(oldObj, newObj) |
Create enriched comparison object |
enrich(obj) |
Create enriched representation |
comparisonToDict(node) |
Serialize comparison tree to plain object |
comparisonToFlatList(node, options?) |
Flatten comparison to leaf change list |
interface Options {
arrayIdentityKeys?: Record<string, string | FunctionKey> | Map<string | RegExp, string | FunctionKey>;
/** @deprecated Use arrayIdentityKeys instead */
embeddedObjKeys?: Record<string, string | FunctionKey> | Map<string | RegExp, string | FunctionKey>;
keysToSkip?: readonly string[];
treatTypeChangeAsReplace?: boolean; // default: true
}- No action required -- all v4 APIs work identically in v5.
- Adopt JSON Atom -- use
diffAtom()/applyAtom()for new code. - Bridge existing data --
toAtom()/fromAtom()for interop with stored v4 changesets. - Rename
embeddedObjKeystoarrayIdentityKeys-- the old name still works, butarrayIdentityKeysis the preferred name going forward. - Both formats coexist. No forced migration.
| Feature | json-diff-ts | deep-diff | jsondiffpatch | RFC 6902 |
|---|---|---|---|---|
| TypeScript | Native | Partial | Definitions only | Varies |
| Bundle Size | ~21KB | ~45KB | ~120KB+ | Varies |
| Dependencies | Zero | Few | Many | Varies |
| ESM Support | Native | CJS only | CJS only | Varies |
| Array Identity | Key-based | Index only | Configurable | Index only |
| Wire Format | JSON Atom (standardized) | Proprietary | Proprietary | JSON Pointer |
| Reversibility | Built-in (oldValue) |
Manual | Plugin | Not built-in |
Q: How does JSON Atom compare to JSON Patch (RFC 6902)?
JSON Patch uses JSON Pointer (/items/0) for paths, which breaks when array elements are inserted, deleted, or reordered. JSON Atom uses JSONPath filter expressions ($.items[?(@.id==1)]) for stable, key-based identity. JSON Atom also supports built-in reversibility via oldValue.
Q: Can I use this with React / Vue / Angular? Yes. json-diff-ts works in any JavaScript runtime -- browsers, Node.js, Deno, Bun, edge workers.
Q: Is it suitable for large objects? Yes. The library handles large, deeply nested JSON structures efficiently with zero dependencies and a ~6KB gzipped footprint.
Q: Can I use the v4 API alongside JSON Atom?
Yes. Both APIs coexist. Use toAtom() / fromAtom() to convert between formats.
Q: What about arrays of primitives?
Use $value as the identity key: { arrayIdentityKeys: { tags: '$value' } }. Elements are matched by value identity.
-
v5.0.0-alpha.2:
- Atom workflow helpers:
squashAtoms,atomMap,atomStamp,atomGroupBy - Atom/operation introspection:
operationSpecDict,operationExtensions,atomSpecDict,atomExtensions,leafProperty - Comparison serialization:
comparisonToDict,comparisonToFlatList
- Atom workflow helpers:
-
v5.0.0-alpha.0:
- JSON Atom API:
diffAtom,applyAtom,revertAtom,invertAtom,toAtom,fromAtom,validateAtom - Canonical path production with typed filter literals
- Conformance with the JSON Atom Specification v0
- Renamed
embeddedObjKeystoarrayIdentityKeys(old name still works as deprecated alias) - All v4 APIs preserved unchanged
- JSON Atom API:
-
v4.9.0:
- Fixed
applyChangesetandrevertChangesetfor root-level arrays containing objects (fixes #362) - Fixed
compareon root-level arrays producing unexpected UNCHANGED entries (fixes #358) - Refactored
applyChangelistpath resolution for correctness with terminal array indices keysToSkipnow acceptsreadonly string[](fixes #359)keyBycallback now receives the element index (PR #365)- Enhanced array handling for
undefinedvalues (fixes #316) - Fixed typo in warning message (#361)
- Fixed README Options Interface formatting (#360)
- Fixed
-
v4.8.2: Fixed array handling in
applyChangesetfor null, undefined, and deleted elements (fixes issue #316) -
v4.8.1: Improved documentation with working examples and detailed options.
-
v4.8.0: Significantly reduced bundle size by completely removing es-toolkit dependency and implementing custom utility functions.
-
v4.7.0: Optimized bundle size and performance by replacing es-toolkit/compat with es-toolkit for difference, intersection, and keyBy functions
-
v4.6.3: Fixed null comparison returning update when values are both null (fixes issue #284)
-
v4.6.2: Fixed updating to null when
treatTypeChangeAsReplaceis false -
v4.6.1: Consistent JSONPath format for array items (fixes issue #269)
-
v4.6.0: Fixed filter path regex to avoid polynomial complexity
-
v4.5.0: Switched internal utilities from lodash to es-toolkit/compat for a smaller bundle size
-
v4.4.0: Fixed Date-to-string diff when
treatTypeChangeAsReplaceis false -
v4.3.0: Added support for nested keys to skip using dotted path notation (fixes #242)
-
v4.2.0: Improved stability with multiple fixes for atomize/unatomize, apply/revert, null handling
-
v4.1.0: Full support for ES modules while maintaining CommonJS compatibility
-
v4.0.0: Renamed flattenChangeset/unflattenChanges to atomizeChangeset/unatomizeChangeset; added treatTypeChangeAsReplace option
Contributions are welcome! Please follow the provided issue templates and code of conduct.
If you find this library useful, consider supporting its development:
- LinkedIn: Christian Glessner
- Twitter: @leitwolf_io
Discover more about the company behind this project: hololux
This project takes inspiration and code from diff-json by viruschidai@gmail.com.
json-diff-ts is open-sourced software licensed under the MIT license.
The original diff-json project is also under the MIT License. For more information, refer to its license details.
