delta:// — Typed JSON model diffing for TypeScript.
Diff any two JSON values and get a structured, typed result with JSON Pointer paths. Apply it forward with patch, reverse it with unpatch, extract a sparse snapshot with snapshot, or export it as an RFC 6902 patch.
import { diff, patch, unpatch } from '@websublime/delta'
const before = { users: [{ id: 1, role: 'admin' }, { id: 2, role: 'user' }] }
const after = { users: [{ id: 2, role: 'mod' }, { id: 1, role: 'admin' }] }
const result = diff(before, after, { arrayIdentity: 'id' })
// → { hasChanges: true, summary: { moved: 2, movedAndChanged: 1, ... }, operations: [...] }
const forward = patch(before, result) // === after
const backward = unpatch(after, result) // === before
// Extract only what changed — ideal for PATCH payloads or audit logs
import { snapshot } from '@websublime/delta'
const sparse = snapshot(result)
// → { users: { '0': { id: 2, role: 'mod' }, '1': { id: 1, role: 'admin' } } }- Zero runtime dependencies — pure TypeScript
- Typed operations —
add | remove | replace | move, each with the right shape - JSON Pointer paths (RFC 6901) —
/users/0/role,~0and~1escaping included - Identity-based array diffing — track items by id across reorders, adds, removes; deterministic even with duplicate ids
- Sparse snapshot —
snapshot()returns a minimal object with only changed fields (removals asnull) — ready for PATCH payloads, form dirty tracking, or audit logs - Bidirectional —
patchandunpatchboth work from the diff result alone;oldValueis always present on destructive ops - RFC 6902 adapter — export any diff as a standard JSON Patch
- Runtime validation —
patch/unpatchreject malformed inputs with a typedDeltaError - Cycle-safe — circular references throw
DeltaError('CIRCULAR_REFERENCE')instead of stack overflow - Prototype-safe — handles
__proto__/constructoras data without pollutingObject.prototype
npm install @websublime/deltaimport { diff } from '@websublime/delta'
const result = diff({ a: 1, b: 2 }, { a: 99, c: 3 })
result.hasChanges // true
result.summary
// { added: 1, removed: 1, replaced: 1, moved: 0, movedAndChanged: 0, total: 3 }
result.operations
// [
// { op: 'replace', path: '/a', value: 99, oldValue: 1 },
// { op: 'remove', path: '/b', oldValue: 2 },
// { op: 'add', path: '/c', value: 3 },
// ]
result.changedPaths // Set<string> — { '/a', '/b', '/c' }import { diff, patch, unpatch } from '@websublime/delta'
const before = { x: 1 }
const after = { x: 2 }
const result = diff(before, after)
patch(before, result) // { x: 2 }
unpatch(after, result) // { x: 1 }Neither function mutates its inputs. unpatch only needs after + the diff result — it never needs before because oldValue is always stored on destructive operations.
Extract a sparse object containing only the values that changed — useful for HTTP PATCH payloads, form dirty tracking, optimistic UI updates, or audit logs.
import { diff, snapshot } from '@websublime/delta'
const before = { name: 'Alice', age: 30, email: 'alice@example.com' }
const after = { name: 'Bob', age: 30, role: 'admin' }
snapshot(diff(before, after))
// → { name: 'Bob', role: 'admin', email: null }Nested structure is sparse — only the branches that actually changed appear:
const before = { user: { name: 'Alice', settings: { theme: 'dark', lang: 'en' } } }
const after = { user: { name: 'Alice', settings: { theme: 'light', lang: 'en' } } }
snapshot(diff(before, after))
// → { user: { settings: { theme: 'light' } } }Removed keys appear as null:
snapshot(diff({ a: 1, b: 2 }, { a: 1 }))
// → { b: null }Root replacements return the new value directly:
snapshot(diff(1, 2)) // → 2
snapshot(diff('hello', { x: 1 })) // → { x: 1 }Returns null when nothing changed:
snapshot(diff({ a: 1 }, { a: 1 })) // → nullWhen your array items have a stable identifier, use arrayIdentity to track them across reorders, adds and removes:
const before = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Carol' },
]
const after = [
{ id: 3, name: 'Carol' },
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]
const result = diff(before, after, { arrayIdentity: 'id' })
// summary: { moved: 3 }
// operations: 3 × { op: 'move', path: '', fromIndex, toIndex, value, oldValue }Without arrayIdentity, the LCS algorithm would emit removes and adds for the reorder instead.
Identity options:
// Single key
{ arrayIdentity: 'id' }
// Composite key
{ arrayIdentity: ['namespace', 'name'] }
// Custom function
{ arrayIdentity: (item) => `${item.type}:${item.id}` }
// Per-path (different rules for different arrays)
{
arrayIdentity: {
'/users': 'id',
'/tags': 'slug',
'/matrix': (item) => `${item.row}:${item.col}`,
}
}Duplicate ids. Internally, each raw id is suffixed with an occurrence counter (1:0, 1:1, …) so duplicates are matched positionally within the same id. No silent data loss.
diff(before, after, {
// Ignore specific paths (exact or wildcard). `/meta/*` ignores every path
// starting with `/meta/`, including new keys being added below it.
ignore: ['/meta/updatedAt', '/meta/*'],
// Stop recursing deeper than N levels. At depth N, emits a full `replace`.
maxDepth: 5,
// Custom equality (e.g. fuzzy number comparison)
equal: (a, b) => Math.abs(a - b) < 0.001,
// Disable move detection (emit remove+add instead)
detectMoves: false,
// Identity config (see above)
arrayIdentity: 'id',
// Skip deep-cloning values into operations. Reduces memory ~2× for large
// diffs, but operations hold references into inputs. Default: true.
cloneValues: false,
})Export any diff result as a standard RFC 6902 JSON Patch:
import { diff } from '@websublime/delta'
import { toRFC6902, toRFC6902JSON } from '@websublime/delta/rfc6902'
const result = diff({ a: 1 }, { a: 2, b: 3 })
toRFC6902(result)
// [
// { op: 'replace', path: '/a', value: 2 },
// { op: 'add', path: '/b', value: 3 },
// ]
toRFC6902JSON(result)
// '[{"op":"replace","path":"/a","value":2},{"op":"add","path":"/b","value":3}]'Tested against fast-json-patch as the reference consumer.
patch and unpatch throw a typed DeltaError when handed a malformed
DiffResult or when the underlying traversal fails:
import { DeltaError, patch } from '@websublime/delta'
try {
patch({}, untrustedPayload)
} catch (err) {
if (err instanceof DeltaError) {
console.error(err.code, err.path, err.message)
// err.code is one of:
// 'CIRCULAR_REFERENCE' | 'INVALID_DIFF_RESULT' | 'INVALID_OPERATION'
// | 'PATH_NOT_FOUND' | 'UNSUPPORTED_VALUE'
}
}type DiffOp = OpAdd | OpRemove | OpReplace | OpMove
interface OpAdd { op: 'add'; path: string; value: JsonValue }
interface OpRemove { op: 'remove'; path: string; oldValue: JsonValue }
interface OpReplace { op: 'replace'; path: string; value: JsonValue; oldValue: JsonValue }
interface OpMove {
op: 'move'
path: string // array root path (e.g. '/users')
fromIndex: number // original position
toIndex: number // final position
value: JsonValue // final value (may include nested changes)
oldValue: JsonValue
}
interface DiffResult {
hasChanges: boolean
operations: DiffOp[]
summary: DiffSummary
changedPaths: Set<string>
}
interface DiffSummary {
added: number
removed: number
replaced: number
moved: number // moved, content unchanged
movedAndChanged: number
total: number
}Determinism. Given the same inputs, diff always emits the same operation array. Covered by property tests.
Equality. The built-in deepEqual:
- Treats
NaN === NaNastrue(diverges from===, aligns withObject.is). - Treats
+0 === -0astrue(standard===behavior). - Treats
undefinedobject values as the key being absent (JSON semantics).
undefined values. Since undefined is not a valid JSON value, it is stripped on clone and treated as "key absent" during diffing. { a: undefined } ≡ {}.
Array move ops use the final value. When an item moves and its content also changed, the move op carries value (the final state) and oldValue (the original). There is no separate replace op emitted for the nested change — the move op is the full story. This is a trade-off: downstream consumers see "the whole moved item changed", not "field X inside moved item changed".
LCS arrays (no identity). Without arrayIdentity, arrays are diffed positionally using an LCS algorithm. An in-place value change like [1, 2, 3] → [1, 99, 3] emits a remove for 2 and an add for 99 (LCS sees [1, 3] as the common subsequence), not a replace.
LCS size budget. The LCS DP table uses (m+1) × (n+1) Uint32 cells. For two arrays of N items each, memory is ~4 × N² bytes: 40 KB at N=100, 4 MB at N=1000, 400 MB at N=10 000. For very large arrays, either configure arrayIdentity (which uses hash-maps instead) or chunk the diff manually.
Path escaping. / in a key becomes ~1, ~ becomes ~0 per RFC 6901.
Circular references throw. diff, patch, and unpatch detect cycles via a WeakSet and throw DeltaError('CIRCULAR_REFERENCE') rather than overflowing the stack.
Prototype safety. Keys named __proto__, constructor, or prototype are preserved as own data properties — they never trigger the __proto__ setter, so Object.prototype is never polluted.
MIT © websublime