diff
bun add @stopcock/diffCompute a patch between two values, apply it, invert it for undo, compose sequential patches, or rebase concurrent edits with conflict detection. All functions are dual-form.
import { diff, apply, invert, compose } from '@stopcock/diff'
const before = { name: 'Tom', scores: [10, 20] }const after = { name: 'Tom', scores: [10, 20, 30] }
const patch = diff(before, after)// [{ op: 'add', path: ['scores', 2], value: 30 }]
const restored = apply(after, invert(patch))// { name: 'Tom', scores: [10, 20] }The diff algorithm walks objects recursively, uses Myers’ LCS on arrays, and detects moves and renames so you get minimal, meaningful patches instead of brute-force replace operations.
Operations
Section titled “Operations”A Patch is a list of operations:
type Operation = | { op: 'add'; path: Path; value: unknown } | { op: 'remove'; path: Path; oldValue: unknown } | { op: 'replace'; path: Path; oldValue: unknown; newValue: unknown } | { op: 'move'; from: Path; path: Path } | { op: 'rename'; path: Path; oldKey: string; newKey: string } | { op: 'test'; path: Path; value: unknown }add, remove, replace are the core three. move and rename are detected automatically when diffing. test is an assertion (used in JSON Patch for safety checks).
Diffing
Section titled “Diffing”diff(before, after): PatchdiffWith(before, after, options): PatchOptions:
eq- custom equality function (default:===)detectMoves- detect array element moves (default:true)detectRenames- detect object key renames (default:true)
// Move detectiondiff( { items: ['a', 'b', 'c'] }, { items: ['c', 'a', 'b'] },)// [{ op: 'move', from: ['items', 2], path: ['items', 0] }]
// Rename detectiondiff( { firstName: 'Tom' }, { name: 'Tom' },)// [{ op: 'rename', path: [], oldKey: 'firstName', newKey: 'name' }]Applying patches
Section titled “Applying patches”apply(target, patch): Result<T, PatchError>applyUnsafe(target, patch): Tapply returns a Result. If an operation fails (path doesn’t exist, index out of bounds), you get a PatchError with context. applyUnsafe throws instead.
All application is immutable. Objects and arrays are shallow-copied at each level of the path.
Inversion
Section titled “Inversion”invert(patch): PatchReverses a patch. add becomes remove, remove becomes add, replace swaps values. Operations are also reversed in order so dependencies are satisfied.
This is what powers undo in @stopcock/state.
const p = diff(a, b)apply(b, invert(p)) // back to aComposition
Section titled “Composition”compose(p1, p2): PatchMerges two sequential patches into one. Automatically simplifies:
addthenremoveat the same path cancels outreplacethenreplaceat the same path merges into oneaddthenreplaceat the same path becomes a singleadd
const p1 = diff(a, b)const p2 = diff(b, c)const p3 = compose(p1, p2)
// apply(a, p3) === apply(apply(a, p1), p2)Rebasing
Section titled “Rebasing”rebase(localPatch, remotePatch): Result<Patch, ConflictError>You and someone else both edited the same base state. The remote patch already landed. rebase transforms your local patch so it applies cleanly on top of the remote changes.
const base = { name: 'Tom', scores: [10, 20] }
const local = diff(base, { name: 'Tom', scores: [10, 20, 30] })const remote = diff(base, { name: 'Tom', scores: [5, 10, 20] })
const rebased = rebase(local, remote)// local's add at index 2 shifts to index 3Array index adjustments happen automatically. If both sides replace the same path, you get a ConflictError. If the remote deletes a path your local patch touches, the local operation is dropped.
JSON Patch (RFC 6902)
Section titled “JSON Patch (RFC 6902)”toJsonPatch(patch): JsonPatchOperation[]fromJsonPatch(ops): PatchConvert to and from the standard JSON Patch format. Useful for sending patches over the wire or storing them in a database.
const p = diff(before, after)const json = toJsonPatch(p)// [{ op: 'add', path: '/scores/2', value: 30 }]
const roundTripped = fromJsonPatch(json)Optics bridge
Section titled “Optics bridge”If you’re already using lenses from @stopcock/fp, you can convert between patches and lenses.
toLens(operation): Lens | nullfromLens(source, lens, target): Patch | nullfromTraversal(source, traversal, fn): PatchAPI reference
Section titled “API reference”type PathSegment = string | numbertype Path = readonly PathSegment[]
type Patch = { readonly _tag: 'Patch'; readonly ops: readonly Operation[] }type PatchError = { readonly _tag: 'PatchError'; message: string; op: Operation; path: Path }type ConflictError = { readonly _tag: 'ConflictError'; message: string; local: Operation; remote: Operation }Constructors
Section titled “Constructors”patch(ops: Operation[]): Patchempty(): Patchops(p: Patch): Operation[]size(p: Patch): numberisEmpty(p: Patch): booleandiff(a, b): PatchdiffWith(a, b, options: DiffOptions): Patchapply(target, patch): Result<T, PatchError>applyUnsafe(target, patch): Tinvert(patch): Patchcompose(p1, p2): Patchrebase(local, remote): Result<Patch, ConflictError>JSON Patch
Section titled “JSON Patch”toJsonPatch(patch): JsonPatchOperation[]fromJsonPatch(ops: JsonPatchOperation[]): PatchOptics
Section titled “Optics”toLens(op: Operation): Lens | nullfromLens(source, lens, target): Patch | nullfromTraversal(source, traversal, fn): Patch