Structural diffs for object graphs with cycle safety and configurable array semantics.
object-graph-delta computes deterministic CREATE / CHANGE / REMOVE events between two object/array graphs.
It is designed for scenarios where you need path-based change events instead of just boolean equality.
- Deterministic diff events: Emits
REMOVEandCHANGEfirst, thenCREATE, in stable key/index order. - Operational array policies: Choose between
diff,atomic, andignorearray handling. - Cycle-safe traversal: Optional pair-based circular reference tracking to prevent infinite recursion.
- Rich-type equality: Correct value comparison for
Date,RegExp, and boxed primitives. - Object key filtering: Skip specific object keys via
keysToSkipwithout affecting array indices. - TypeScript-friendly API: Strongly typed paths and diff result unions.
| Need | What this package gives you |
|---|---|
| Patch-like change events | CREATE / CHANGE / REMOVE with explicit paths |
| Fine control over array behavior | Index diffing, atomic compare, or full ignore |
| Safe comparison of cyclic graphs | Built-in cycle tracking (trackCircularReferences) |
| Correct treatment of tricky JS values | Object.is semantics, rich-type handling, boxed primitive equality |
Each diff entry includes a path ((string | number)[]) and one of:
CREATE:{ type: 'CREATE', path, value }REMOVE:{ type: 'REMOVE', path, oldValue }CHANGE:{ type: 'CHANGE', path, value, oldValue }
Example path:
['users', 0, 'name']meansroot.users[0].name
npm install object-graph-delta
# or
pnpm add object-graph-delta
# or
yarn add object-graph-deltaimport { diff } from 'object-graph-delta';
const previous = { a: 1, b: 2 };
const current = { b: 3, c: 4 };
const changes = diff(previous, current);
console.log(changes);Output:
[
{ type: 'REMOVE', path: ['a'], oldValue: 1 },
{ type: 'CHANGE', path: ['b'], value: 3, oldValue: 2 },
{ type: 'CREATE', path: ['c'], value: 4 }
]Array handling is the core runtime policy switch.
arrayPolicy |
Behavior | Emits |
|---|---|---|
'diff' |
Traverses arrays by index recursively | Per-index CREATE / CHANGE / REMOVE |
'atomic' |
Treats each array as one leaf value | At most one CHANGE at the array path |
'ignore' |
Skips array comparison entirely | Nothing for arrays |
Used only when arrayPolicy: 'atomic':
arrayEquality |
Equality rule |
|---|---|
'reference' |
Equal only if a === b |
'shallow' |
Equal if same length and each element matches via Object.is |
Default options are:
{
trackCircularReferences: true,
arrayPolicy: 'diff',
keysToSkip: []
}When arrayPolicy is 'atomic' and arrayEquality is omitted, 'shallow' is used.
arrayEquality is only relevant in atomic mode. Set it explicitly when using arrayPolicy: 'atomic'.
import { diff } from 'object-graph-delta';
diff({ a: 1 }, { a: 2 });
// [{ type: 'CHANGE', path: ['a'], value: 2, oldValue: 1 }]import { diff } from 'object-graph-delta';
diff({ list: [1, 2, 3] }, { list: [1, 3] });
// [
// { type: 'CHANGE', path: ['list', 1], value: 3, oldValue: 2 },
// { type: 'REMOVE', path: ['list', 2], oldValue: 3 }
// ]import { diff } from 'object-graph-delta';
const previous = { a: [1, 2] };
const current = { a: [1, 2] };
diff(previous, current, { arrayPolicy: 'atomic', arrayEquality: 'reference' });
// [{ type: 'CHANGE', path: ['a'], value: [1, 2], oldValue: [1, 2] }]
diff(previous, current, { arrayPolicy: 'atomic', arrayEquality: 'shallow' });
// []import { diff } from 'object-graph-delta';
diff(
{ items: [1, 2, 3], version: 1 },
{ items: [9, 8, 7], version: 2 },
{ arrayPolicy: 'ignore' }
);
// [{ type: 'CHANGE', path: ['version'], value: 2, oldValue: 1 }]import { diff } from 'object-graph-delta';
const previous: Record<string, unknown> = { a: 1, meta: { changed: false } };
previous.self = previous;
const current: Record<string, unknown> = { a: 2, meta: { changed: true } };
current.self = current;
diff(previous, current, {
trackCircularReferences: true,
keysToSkip: ['meta']
});
// [{ type: 'CHANGE', path: ['a'], value: 2, oldValue: 1 }]The public API compares two root containers (objects or arrays):
import { diff } from 'object-graph-delta';
const previous = { user: { name: 'Alice' } };
const current = { user: { name: 'Bob' } };const result = diff(previous, current, {
arrayPolicy: 'diff',
trackCircularReferences: true,
keysToSkip: ['_internal']
});for (const change of result) {
if (change.type === 'CHANGE') {
// change.path, change.oldValue, change.value
}
}diff<V>(
previous: Container<V>,
current: Container<V>,
options?: DiffOptions
): DiffResult<Node<V>>[]| Option | Type | Default | Description |
|---|---|---|---|
trackCircularReferences |
boolean |
true |
Prevents infinite recursion by tracking compared container pairs on the current traversal path |
arrayPolicy |
'diff' | 'atomic' | 'ignore' |
'diff' |
Controls array handling mode |
arrayEquality |
'reference' | 'shallow' |
'shallow' (when arrayPolicy: 'atomic' and omitted) |
Used in atomic mode to decide array equality |
keysToSkip |
readonly string[] |
[] |
Skips object keys (does not apply to array indices) |
- Equality is based on
Object.isfor leaf values. NaNequalsNaN;+0and-0are different.Datevalues are compared by timestamp.RegExpvalues are compared by string form (/pattern/flags).- Boxed primitives (
new Number,new String,new Boolean,Object(1n)) compare by underlying value. - Symbol keys and non-enumerable keys are ignored (iteration is based on
Object.keys). - Inherited keys can affect presence/value checks because key existence uses the
inoperator. - Alias paths in cyclic structures can yield multiple diffs for the same logical value change at different paths.