Deterministic JSON state transitions with key-based array identity. A TypeScript JSON diff library that computes, applies, and reverts atomic changes using the JSON Delta 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 { diffDelta, applyDelta, revertDelta } 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 delta = diffDelta(before, after, { arrayIdentityKeys: { items: 'id' } });The delta tracks what changed, not where it moved:
{
"format": "json-delta",
"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 — applyDelta mutates the input object
const updated = applyDelta(structuredClone(before), delta); // updated === after
const restored = revertDelta(structuredClone(updated), delta); // restored === beforeimport { diffDelta, applyDelta, revertDelta } 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 a delta between two JSON objects
const delta = diffDelta(oldObj, newObj, {
arrayIdentityKeys: { items: 'id' }, // match array elements by 'id' field
});
// delta.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 delta to produce the new state
const updated = applyDelta(structuredClone(oldObj), delta);
// 3. Revert the delta to restore the original state
const reverted = revertDelta(structuredClone(updated), delta);That's it. delta 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 { diffDelta, applyDelta, revertDelta } from 'json-diff-ts';
// CommonJS
const { diffDelta, applyDelta, revertDelta } = require('json-diff-ts');JSON Delta is a specification for representing atomic changes to JSON documents. json-diff-ts is the originating implementation from which the spec was derived.
json-delta-format (specification)
├── json-diff-ts (TypeScript implementation) ← this package
└── json-delta-py (Python implementation)
The specification defines the wire format. Each language implementation produces and consumes compatible deltas.
A delta 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 deltas 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 Delta 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 Delta | 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-delta-format | RFC 6902 |
const delta = diffDelta(
{ user: { name: 'Alice', role: 'viewer' } },
{ user: { name: 'Alice', role: 'admin' } }
);
// delta.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 delta = diffDelta(
{ users: [{ id: 1, role: 'viewer' }, { id: 2, role: 'editor' }] },
{ users: [{ id: 1, role: 'admin' }, { id: 2, role: 'editor' }] },
{ arrayIdentityKeys: { users: 'id' } }
);
// delta.operations → [{ op: 'replace', path: '$.users[?(@.id==1)].role', value: 'admin', oldValue: 'viewer' }]Omit oldValue fields when you don't need undo:
const delta = diffDelta(source, target, { reversible: false });Applies operations sequentially. Always use the return value (required for root-level replacements):
const result = applyDelta(structuredClone(source), delta);Computes the inverse and applies it. Requires oldValue on all replace and remove operations:
const original = revertDelta(structuredClone(target), delta);Returns a new delta that undoes the original (spec Section 9.2):
const inverse = invertDelta(delta);
// add ↔ remove, replace swaps value/oldValue, order reversedconst { valid, errors } = validateDelta(maybeDelta);| Function | Signature | Description |
|---|---|---|
diffDelta |
(oldObj, newObj, options?) => IJsonDelta |
Compute a canonical JSON Delta |
applyDelta |
(obj, delta) => any |
Apply a delta sequentially. Returns the result |
revertDelta |
(obj, delta) => any |
Revert a reversible delta |
invertDelta |
(delta) => IJsonDelta |
Compute the inverse delta |
validateDelta |
(delta) => { valid, errors } |
Structural validation |
toDelta |
(changeset, options?) => IJsonDelta |
Bridge: v4 changeset to JSON Delta |
fromDelta |
(delta) => IAtomicChange[] |
Bridge: JSON Delta to v4 atomic changes |
Extends the base Options interface:
interface DeltaOptions extends Options {
reversible?: boolean; // Include oldValue for undo. Default: true
arrayIdentityKeys?: Record<string, string | FunctionKey>;
keysToSkip?: readonly string[];
}Store every change to a document as a reversible delta. Each entry records who changed what, when, and can be replayed or reverted independently -- a complete JSON change tracking system:
import { diffDelta, applyDelta, revertDelta, IJsonDelta } from 'json-diff-ts';
interface AuditEntry {
timestamp: string;
userId: string;
delta: IJsonDelta;
}
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 delta = diffDelta(doc, newDoc, {
arrayIdentityKeys: { items: 'id' },
});
if (delta.operations.length > 0) {
auditLog.push({ timestamp: new Date().toISOString(), userId, delta });
doc = applyDelta(structuredClone(doc), delta);
}
return doc;
}
// Revert the last change
function undo(): typeof doc {
const last = auditLog.pop();
if (!last) return doc;
doc = revertDelta(structuredClone(doc), last.delta);
return doc;
}
// Example usage:
updateDocument(
{ ...doc, status: 'active', items: [{ id: 1, task: 'Design', done: true }, ...doc.items.slice(1)] },
'alice'
);
// auditLog[0].delta.operations =>
// [
// { op: 'replace', path: '$.status', value: 'active', oldValue: 'draft' },
// { op: 'replace', path: '$.items[?(@.id==1)].done', value: true, oldValue: false }
// ]Because every delta 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. Deltas are small (only changed fields), reversible, and serializable:
import { diffDelta, applyDelta, revertDelta, IJsonDelta } from 'json-diff-ts';
class UndoManager<T extends object> {
private undoStack: IJsonDelta[] = [];
private redoStack: IJsonDelta[] = [];
constructor(private state: T) {}
apply(newState: T): T {
const delta = diffDelta(this.state, newState);
if (delta.operations.length === 0) return this.state;
this.undoStack.push(delta);
this.redoStack = [];
this.state = applyDelta(structuredClone(this.state), delta);
return this.state;
}
undo(): T {
const delta = this.undoStack.pop();
if (!delta) return this.state;
this.redoStack.push(delta);
this.state = revertDelta(structuredClone(this.state), delta);
return this.state;
}
redo(): T {
const delta = this.redoStack.pop();
if (!delta) return this.state;
this.undoStack.push(delta);
this.state = applyDelta(structuredClone(this.state), delta);
return this.state;
}
}Send only what changed between client and server. Deltas are compact -- a single field change in a 10KB document produces a few bytes of delta, making state synchronization efficient over the wire:
import { diffDelta, applyDelta, validateDelta } from 'json-diff-ts';
// Client side: compute and send delta
const delta = diffDelta(localState, updatedState, {
arrayIdentityKeys: { records: 'id' },
});
await fetch('/api/sync', {
method: 'POST',
body: JSON.stringify(delta),
});
// Server side: validate and apply
const result = validateDelta(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 = applyDelta(structuredClone(currentState), req.body);Convert between the legacy internal format and JSON Delta:
import { diff, toDelta, fromDelta, unatomizeChangeset } from 'json-diff-ts';
// v4 changeset → JSON Delta
const changeset = diff(source, target, { arrayIdentityKeys: { items: 'id' } });
const delta = toDelta(changeset);
// JSON Delta → v4 atomic changes
const atoms = fromDelta(delta);
// v4 atomic changes → hierarchical changeset (if needed)
const cs = unatomizeChangeset(atoms);Note: toDelta is a best-effort bridge. Filter literals are always string-quoted (e.g., [?(@.id=='42')] instead of canonical [?(@.id==42)]). Use diffDelta() for fully canonical output.
All v4 APIs remain fully supported. Existing code continues to work without changes. For new projects, prefer the JSON Delta 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 |
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 Delta -- use
diffDelta()/applyDelta()for new code. - Bridge existing data --
toDelta()/fromDelta()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 Delta (standardized) | Proprietary | Proprietary | JSON Pointer |
| Reversibility | Built-in (oldValue) |
Manual | Plugin | Not built-in |
Q: How does JSON Delta 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 Delta uses JSONPath filter expressions ($.items[?(@.id==1)]) for stable, key-based identity. JSON Delta 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 Delta?
Yes. Both APIs coexist. Use toDelta() / fromDelta() 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.0:
- JSON Delta API:
diffDelta,applyDelta,revertDelta,invertDelta,toDelta,fromDelta,validateDelta - Canonical path production with typed filter literals
- Conformance with the JSON Delta Specification v0
- Renamed
embeddedObjKeystoarrayIdentityKeys(old name still works as deprecated alias) - All v4 APIs preserved unchanged
- JSON Delta 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.
