Skip to content

Diffing

Get structured change records from two object snapshots.

Previous: Tutorial · Next: Patching

Playground

Open Diff explorer → — edit before/after JSON and inspect change records live.

Import path

ts
import { diff, hasChanges, compare } from "@jayoncode/object-diff";
// Slim (no patch/serialize): import { diff, hasChanges } from "@jayoncode/object-diff/core";

Subpath map: Engines.

Problem → approach

Without structured diffWith diff()
String compare or shallow === misses nested editsDeep walk with path-addressable change records
Building a change list by hand for every form/storeresult.changes with type, path, previous, current
Full diff cost when you only need a dirty flaghasChanges() short-circuits without materializing changes

Basics

ts
import { diff } from "@jayoncode/object-diff";

const result = diff({ user: { name: "John" }, count: 1 }, { user: { name: "Jane" }, count: 2 });

console.log(result.changes);
console.log(result.metadata.changeCount);

Dirty check only

ts
import { hasChanges } from "@jayoncode/object-diff";

if (!hasChanges(savedState, currentState)) {
  return; // nothing to persist
}

Prefer importing from @jayoncode/object-diff/core when you only need compare/diff/hasChanges (smaller tree-shaken graph).

Equality & supported types

diff() and compare() share the same deep-equality core. Two values are considered equal when:

KindEquality rule
PrimitivesObject.is semantics — NaN equals NaN, +0/-0 are distinct
Datea.getTime() === b.getTime() (two Invalid Dates are equal)
RegExpa.source === b.source && a.flags === b.flags
MapSame size, every key's value deep-equal (keys matched by ===/Object.is)
SetSame size, every member has a deep-equal counterpart (order-independent)
Typed arrays (Uint8Array, etc.)Same byteLength and identical bytes
FunctionsReference equality (===) only — never compared by source or behavior
Plain objects / arraysDeep, recursive structural comparison

Mismatched kinds (e.g. a Date vs a plain object) are always unequal. Exotic types (WeakMap, WeakSet, class instances, etc.) fall through to plain-object handling — own enumerable keys only.

treatUndefinedAsMissing

Default: false. For nested properties, a key holding an explicit undefined is already treated the same as an absent key regardless of this option — both produce added/removed records:

ts
diff({ a: 1, b: 2 }, { a: 1, b: undefined }); // → removed "b" (same with or without this option)
diff({ a: 1, b: 2 }, { a: 1 }); // → removed "b" (key omitted entirely — identical result)

treatUndefinedAsMissing only changes behavior when the values passed directly to diff()/hasChanges() themselves are undefined (the root comparison, before any key is walked):

ts
diff(undefined, { a: 1 });
// default: → changed "" (root), current: { a: 1 } — undefined is compared as a value

diff(undefined, { a: 1 }, { treatUndefinedAsMissing: true });
// → added "" (root) — undefined at the root is treated as "nothing there yet"

Set it to true if you diff optional/not-yet-loaded root values (e.g. diff(previousOrUndefined, current)) and want that case reported as added/removed rather than changed.

Options

ts
diff(before, after, {
  maxDepth: 20,
  includeUnchanged: false,
  ignore: ["password", "meta.*"],
  include: ["user.**"],
  identityKey: "id", // match array items by identity instead of index
  detectMoves: true, // pair equal removed+added into type: "moved"
  treatUndefinedAsMissing: false,
  circular: "error", // or "skip"
  customComparator: (a, b, path) => undefined, // true/false/undefined
});
OptionDefaultEffect
maxDepthInfinityThrows MaxDepthExceededError once traversal depth exceeds this value
includeUnchangedfalseKeep unchanged records in changes (otherwise only counted in metadata.unchangedCount)
detectMovesfalseEmit moved for array reorders (LCS + equality / identity) and coalesce equal object-key remove+add pairs into moved (from + path)
circular"error""error" throws CircularReferenceError on a repeated object/Map/Set reference; "skip" stops descending into it
customComparatorundefined(a, b, path) => boolean | undefinedtrue/false overrides equality at that path, undefined falls back to default logic
treatUndefinedAsMissingfalseTreat an explicit undefined value like an absent key (see above)
identityKeyundefinedMatch array items by a stable id (property name or (item, path) => id) instead of index; duplicate ids on either side throw InvalidOptionsError
ignoreundefinedPath globs (* = one segment, ** = any remainder) to skip
includeundefinedOnly emit changes for matching paths (ancestors are still visited so descendants stay reachable)

ignore / include — prefix vs. glob vs. emit rules

ignore/include affect two things: which paths get visited during traversal, and which resulting changes get emitted. Both share glob syntax, but plain strings (no *) get extra "prefix" behavior:

  • Exact string (no *): ignore: ["secrets"] skips secrets itself and every descendant (secrets.token, secrets[0]) — acts as a subtree prefix, not just an exact path.
  • .** suffix: ignore: ["secrets.**"] skips descendants only (secrets.token, secrets[0]). If the secrets value itself changes, a change is still emitted at secrets (without listing ignored child paths).
  • * matches exactly one segment: include: ["user.*"] matches user.name, not user.address.city.
  • ** matches zero or more segments: include: ["user.**"] matches user, user.name, and user.address.city.
  • include still visits ancestors: include: ["user.name"] visits user (and the root) so the walk can reach user.name, but only user.name is emitted as a change.
  • ignore wins over include when both match the same path.
ts
diff(before, after, { ignore: ["password", "meta.*"] }); // drop "password" subtree + one level under "meta"
diff(before, after, { ignore: ["secrets.**"] }); // ignore nested noise; still report if `secrets` itself changed
diff(before, after, { include: ["user.**"] }); // only emit changes under "user"

CompareOptions is a subset of DiffOptions

compare(a, b, options) takes CompareOptions — only maxDepth, circular, and customComparator. There is no ignore, include, identityKey, or treatUndefinedAsMissing on compare(): it answers a single boolean "are these equal?", so per-path filtering and array-identity matching (which only matter when producing change records) don't apply. For filtered or identity-aware equality checks, use diff()/hasChanges(), which accept the full DiffOptions.

DiffRecord.previous / current are omitted when undefined

A DiffRecord only carries the previous and/or current keys when that value isn't undefined — they're conditionally spread onto the record, never present as key: undefined:

ts
diff({ a: 1 }, {}).changes[0];
// → { path: "a", type: "removed", previous: 1 } — no `current` key at all

diff({}, { a: 1 }).changes[0];
// → { path: "a", type: "added", current: 1 } — no `previous` key

Use "previous" in change / "current" in change if you need to distinguish "key absent" from "value is undefined".

Filtered helpers (a, b)

These helpers still take two values (they run a diff internally):

ts
import { added, removed, updated, unchanged } from "@jayoncode/object-diff";

added(before, after);
removed(before, after);
updated(before, after); // type === "changed"
unchanged(before, after); // forces includeUnchanged

For querying an existing DiffResult, use @jayoncode/object-diff/query.

Compare

ts
import { compare } from "@jayoncode/object-diff";

const equal = compare(objA, objB);

Cheat sheet

ts
const result = diff(before, after, { detectMoves: true });
result.changes; // readonly DiffRecord[]
result.metadata; // changeCount, movedCount, …
hasChanges(before, after); // fast path

Next: Patching — turn changes into JSON Patch operations.

An ecosystem of independent, headless TypeScript libraries engineered for modern web development. Every package includes interactive playgrounds and documentation that evolves alongside the code.