Value.diff and Operations.patch
Changeset
A Changeset is the result type returned by Value.diff. It holds an ordered list of JSON Pointer-based operations (set / delete) that transform one value into another. Key members:
.isEmpty:truewhen no operations were produced.length: number of operations in the changeset.operations: readonly array ofDiffOpType({ op: 'set', path: string, value: unknown }or{ op: 'delete', path: string })
See Value.diff for usage examples and Operations.patch for applying individual operations.
Value.diff
Declaration. Computes the structural diff between two values and returns a Changeset. The changeset contains an ordered list of JSON Pointer-based operations (set / delete) that transform before into after. Returns Changeset with .isEmpty, .length, .operations (readonly array of DiffOpType). Does not mutate either input.
Use this when you need event sourcing, audit logs, optimistic concurrency checks, undo/redo, or detecting whether two values differ without a full deep-equal check.
Don't use this when you only need a boolean "are these equal?" check - Hash.value(a) === Hash.value(b) is faster for equality. Don't use it inside tight inner loops - it walks both objects recursively.
Examples
Example 1: Detect email change on a customer update
/**
* Value.diff — Example 1: Detect changes, replay changeset
* Demonstrates: operations array, isEmpty
*
* Bastian Balthazar Bux updates the email on their customer record from
* the old antiquariat-era address to a more formal contact.
*/
import {
Operations, Value
} from '../../../src/index.js';
import type { Customer } from '../bookstore/index.js';
import {
aboxFixtures, bookstoreEntities, CustomerSchema
} from '../bookstore/index.js';
const before = bookstoreEntities.instantiate(CustomerSchema, aboxFixtures.customer);
const after = bookstoreEntities.instantiate(CustomerSchema, {
...aboxFixtures.customer,
'email': 'bastian.balthazar.bux@bookstore.example'
});
const changes = Value.diff(before, after);
console.assert(!changes.isEmpty);
console.assert(changes.length > 0);
console.assert(changes.operations.some((op) => {
return op.path === '/email';
}));
// Replay each operation to reconstruct the after value.
let reconstructed: unknown = Operations.clone(before);
for (const operation of changes.operations) {
reconstructed = Operations.patch(reconstructed, operation);
}
console.assert((reconstructed as Customer).email === 'bastian.balthazar.bux@bookstore.example');
console.log('changed fields:', changes.operations.map((op) => {
return op.path;
}));
console.log('reconstructed email:', (reconstructed as Customer).email);
Example 2: Track order line additions
/**
* Value.diff — Example 2: Track order line additions
* Demonstrates: diff detects new items array entry, operations report the paths
*
* Bastian Balthazar Bux's order for the 1979 Thienemann Neverending Story
* gains a second line — a Walter Moers paperback. The diff reports the two
* changed paths: the new orderLines entry and the updated orderTotal.
*/
import {
Operations, Value
} from '../../../src/index.js';
import {
aboxFixtures, bookstoreEntities, OrderSchema
} from '../bookstore/index.js';
const beforeOrder = bookstoreEntities.instantiate(OrderSchema, aboxFixtures.order);
const moersPrice = 24.9;
const afterOrder = bookstoreEntities.instantiate(OrderSchema, {
...aboxFixtures.order,
'orderLines': [
...aboxFixtures.order.orderLines,
// Walter Moers — Die Stadt der Träumenden Bücher (Piper, 2004).
{
'bookIsbn': '9783492045490',
'quantity': 1,
'unitPrice': {
'amount': moersPrice,
'currency': 'EUR'
}
}
],
'orderTotal': {
'amount': aboxFixtures.order.orderTotal.amount + moersPrice,
'currency': 'EUR'
}
});
const changes = Value.diff(beforeOrder, afterOrder);
console.assert(!changes.isEmpty);
// At minimum: new orderLines[1] entry and updated orderTotal
console.assert(changes.length >= 2);
// Replay the changeset to reconstruct afterOrder from beforeOrder.
let reconstructed: unknown = Operations.clone(beforeOrder);
for (const op of changes.operations) {
reconstructed = Operations.patch(reconstructed, op);
}
const reconstructedTotal = (reconstructed as { 'orderTotal': { 'amount': number } }).orderTotal.amount;
console.assert(Math.abs(reconstructedTotal - (aboxFixtures.order.orderTotal.amount + moersPrice)) < 0.001);
console.log('changed paths:', changes.operations.map((op) => {
return op.path;
}));
console.log('reconstructed orderTotal:', reconstructedTotal);
Example 3: Audit log entry
/**
* Value.diff — Example 3: Audit log entry via diff
* Demonstrates: isEmpty guard, operations array forwarded to structured logger
*
* A generic auditUpdate helper diffs before/after records and emits a
* structured log entry when changes are detected. The Bastian Balthazar
* Bux customer record provides the before/after fixture — the email
* address is updated from the antiquariat-era address to a formal one.
*/
import {
Operations, Value
} from '../../../src/index.js';
import type { Customer } from '../bookstore/index.js';
import {
aboxFixtures, bookstoreEntities, CustomerSchema
} from '../bookstore/index.js';
const logEntries: Array<{
'count': number;
'ops': unknown;
'schema': string;
}> = [];
function auditUpdate(schemaId: string, before: unknown, after: unknown): ReturnType<typeof Value.diff> {
const changes = Value.diff(before, after);
if (!changes.isEmpty) {
logEntries.push({
'count': changes.length,
'ops': changes.operations,
'schema': schemaId
});
}
return changes;
}
const before = bookstoreEntities.instantiate(CustomerSchema, aboxFixtures.customer);
const after = bookstoreEntities.instantiate(CustomerSchema, {
...aboxFixtures.customer,
'email': 'bastian.balthazar.bux@bookstore.example'
});
const changes = auditUpdate(CustomerSchema.$id, before, after);
console.assert(!changes.isEmpty);
console.assert(logEntries.length === 1);
console.assert(logEntries[0]?.schema === CustomerSchema.$id);
console.assert((logEntries[0]?.count ?? 0) > 0);
console.assert(changes.operations.some((op) => {
return op.path === '/email';
}));
// No-op diff does not emit a log entry.
const noChanges = auditUpdate(CustomerSchema.$id, before, before);
console.assert(noChanges.isEmpty);
// still 1 — no second entry
console.assert(logEntries.length === 1);
// Replay the changeset to verify roundtrip.
let reconstructed: unknown = Operations.clone(before);
for (const op of changes.operations) {
reconstructed = Operations.patch(reconstructed, op);
}
console.assert((reconstructed as Customer).email === 'bastian.balthazar.bux@bookstore.example');
console.log('log entries emitted:', logEntries.length);
console.log('changed paths:', logEntries[0]?.ops instanceof Array
? (logEntries[0].ops as Array<{ 'path': string }>).map((op) => {
return op.path;
})
: []);
console.log('no-op emits entry:', !noChanges.isEmpty);
console.log('reconstructed email:', (reconstructed as Customer).email);
Comparison
const changes = Value.diff(before, after);
// Changeset - .isEmpty, .length, .operations (JSON Pointer paths)// Zod has no built-in diff. Use a third-party library:
import { diff } from 'microdiff';
const changes = diff(before, after);
// Limitation: microdiff paths use bracket notation, not JSON Pointer; no typed Changeset;
// no built-in `applyOp` - you need fast-json-patch or manual object mutation.// Limitation: Valibot has no diff utility. Use a third-party library:
import { diff } from 'microdiff';
const changes = diff(before, after);
// No typed Changeset, no JSON Pointer paths, no schema awareness.// Limitation: io-ts has no diff utility. Use a third-party library:
import { diff } from 'microdiff';
const changes = diff(before, after);
// No typed Changeset, no JSON Pointer paths, no schema awareness.// TypeBox has no built-in diff.
// Closest: implement manually over Value.Errors or with a deep-diff library.
// Limitation: no standard diff API; output format is library-specific;
// no composable `applyOp` complement.// AJV has no built-in diff.
// Use a third-party library (microdiff, deep-diff) applied after validation.
// Limitation: same as TypeBox - no Changeset, no JSON Pointer paths, no applyOp.# Manual dict comparison:
before_dict = before.model_dump()
after_dict = after.model_dump()
changes = {k: v for k, v in after_dict.items() if before_dict.get(k) != v}
# Or use python-deepdiff for a full diff.// Limitation: feature not directly supported in Yup. See /comparisons for the matrix.// Limitation: feature not directly supported in Joi. See /comparisons for the matrix.// Limitation: feature not directly supported in Effect Schema. See /comparisons for the matrix.// Limitation: feature not directly supported in ArkType. See /comparisons for the matrix.// Limitation: feature not directly supported in Runtypes. See /comparisons for the matrix.Operations.patch
Declaration. Applies a single DiffOpType operation ({ op: 'set', path: string, value: unknown } or { op: 'delete', path: string }) to a value and returns the result. The path is a JSON Pointer string. Does not mutate the input - clone it first if you need the original.
Use this when you want to apply specific operations from a changeset rather than all of them - for example, rolling back one field change in an undo system, or applying real-time patch updates one at a time.
Don't use this when you want to apply all operations at once - use changeset.apply(value) (or loop over changeset.operations and call Operations.patch yourself - see the note about Changeset.apply below).
Note on Changeset.apply
The project lint rules block direct calls to methods named .apply() (to prevent accidental use of Function.prototype.apply). To apply a full changeset, loop over .operations manually:
/**
* Operations.patch — Tip: Apply full changeset via loop (not .apply())
* Demonstrates: the preferred pattern for replaying all operations
*
* The project lint rules block direct calls to methods named .apply() to
* prevent accidental Function.prototype.apply use. Loop over .operations
* and call Operations.patch() on each. The Bastian Balthazar Bux order
* fixture gains a new line item; the loop reconstructs the after state.
*/
import {
Operations, Value
} from '../../../src/index.js';
import {
aboxFixtures, bookstoreEntities, OrderSchema
} from '../bookstore/index.js';
const before = bookstoreEntities.instantiate(OrderSchema, aboxFixtures.order);
const updatedTotal = {
'amount': aboxFixtures.order.orderTotal.amount + 12.99,
'currency': 'EUR'
};
const after = bookstoreEntities.instantiate(OrderSchema, {
...aboxFixtures.order,
'orderLines': [
...aboxFixtures.order.orderLines,
// Cornelia Funke — Tintenherz (Cecilie Dressler Verlag, 2003)
{
'bookIsbn': '9783791504100',
'quantity': 1,
'unitPrice': {
'amount': 12.99,
'currency': 'EUR'
}
}
],
'orderTotal': updatedTotal
});
const changes = Value.diff(before, after);
// ✓ Correct pattern: loop over .operations, call Operations.patch each time.
let result: unknown = Operations.clone(before);
for (const op of changes.operations) {
result = Operations.patch(result, op);
}
const resultTotal = (result as { 'orderTotal': { 'amount': number } }).orderTotal.amount;
console.assert(Math.abs(resultTotal - updatedTotal.amount) < 0.001);
console.log('operations applied:', changes.operations.length);
console.log('patched orderTotal:', resultTotal);
Examples
Example 1: Apply a single price update
/**
* Operations.patch — Example 1: Apply a single price update
* Demonstrates: patch with op:'set', original unchanged, result has new value
*
* The canonical rare book fixture price is updated from €850 to €795
* via a single patch operation. The original book object is cloned first so
* the source is not mutated.
*/
import {
Operations
} from '../../../src/index.js';
import {
aboxFixtures, bookstoreEntities, RareBookSchema
} from '../bookstore/index.js';
const book = bookstoreEntities.instantiate(RareBookSchema, aboxFixtures.rareBook);
const updated = Operations.patch(Operations.clone(book), {
'op': 'set',
'path': '/price/amount',
'value': 795
});
console.assert((updated as { 'price': { 'amount': number } }).price.amount === 795);
// Original is unchanged.
console.assert((book as { 'price': { 'amount': number } }).price.amount === 850);
console.log('original price:', (book as { 'price': { 'amount': number } }).price.amount);
console.log('patched price:', (updated as { 'price': { 'amount': number } }).price.amount);
Comparison
const result = Operations.patch(Operations.clone(book), { op: 'set', path: '/price', value: 12.99 });// Zod has no built-in patch. Use fast-json-patch:
import { applyOperation } from 'fast-json-patch';
const result = applyOperation(clone, { op: 'replace', path: '/price', value: 12.99 }).newDocument;
// Limitation: fast-json-patch uses JSON Patch format (op: 'replace'), not the
// json-tology DiffOpType (op: 'set'). Requires an extra dependency; no type narrowing.// Limitation: Valibot has no patch utility. Use fast-json-patch:
import { applyOperation } from 'fast-json-patch';
const result = applyOperation(clone, { op: 'replace', path: '/price', value: 12.99 }).newDocument;
// Same constraints as Zod - JSON Patch format, extra dependency, no schema awareness.// Limitation: io-ts has no patch utility. Use fast-json-patch:
import { applyOperation } from 'fast-json-patch';
const result = applyOperation(clone, { op: 'replace', path: '/price', value: 12.99 }).newDocument;
// JSON Patch format, extra dependency, no schema-aware mutation.// TypeBox has no built-in diff.
// Closest: implement manually over Value.Errors or with a deep-diff library.
// Limitation: no standard diff API; output format is library-specific;
// no composable patch complement.// AJV has no built-in diff.
// Use a third-party library (microdiff, deep-diff) applied after validation.
// Limitation: same as TypeBox - no Changeset, no JSON Pointer paths, no patch.updated = book.model_copy(update={'price': 12.99})// Limitation: feature not directly supported in Yup. See /comparisons for the matrix.// Limitation: feature not directly supported in Joi. See /comparisons for the matrix.// Limitation: feature not directly supported in Effect Schema. See /comparisons for the matrix.// Limitation: feature not directly supported in ArkType. See /comparisons for the matrix.// Limitation: feature not directly supported in Runtypes. See /comparisons for the matrix.Related
Operations.clone- clone before applying to preserve originalValue.diff- produce the operations to apply
See also
- Bookstore domain - where
Book,Customer,Orderare defined