JSON Patch and JSON Pointer
The JsonPatch and JsonPointer modules are two small, tightly-related core
utilities. JsonPatch computes and applies structural diffs between JSON
documents (a deterministic subset of RFC 6902),
and JsonPointer handles escaping/unescaping of the path segments
(RFC 6901) that JsonPatch uses to
address locations inside a document. Both are plain effect imports:
import { JsonPatch, JsonPointer } from "effect"JsonPatch
Section titled “JsonPatch”A JsonPatch is an ordered list of operations applied left-to-right: each
operation observes the document produced by the operations before it. Locations
are addressed by JSON Pointer paths, where the empty path "" targets the whole
document and /users/0/name targets a nested property or array element.
The common workflow is to compute a diff between a before and after value
with get, then replay it onto the original document with apply.
import { JsonPatch } from "effect"
const before = { title: "draft", tags: ["fp"] }const after = { title: "published", tags: ["fp", "effect"] }
// Compute the structural difference between the two values.const patch = JsonPatch.get(before, after)// => [// => { op: "add", path: "/tags/1", value: "effect" },// => { op: "replace", path: "/title", value: "published" }// => ]
// Replay it onto the original document; `before` is never mutated.const updated = JsonPatch.apply(patch, before)// => { title: "published", tags: ["fp", "effect"] }Mental model
Section titled “Mental model”- A patch is applied from first to last. Later operations see the document state produced by earlier ones.
- Paths are JSON Pointers.
""is the root document; non-empty paths must start with/and are split into reference tokens. - This module implements only the deterministic
add,remove, andreplacesubset of RFC 6902. There is notest,move, orcopy. getcompares JSON structure, not domain meaning. It detects that a value changed, but does not infer semantic edits such as array moves.applynever mutates its input. It copies only the containers it changes and returns a new value. An empty patch returns the original document reference.
JsonPointer
Section titled “JsonPointer”A JSON Pointer is a sequence of reference tokens separated by /. The
JsonPointer module operates on a single token, not a whole pointer — a
full pointer like /foo/bar must be split into its tokens (["foo", "bar"])
first. The two characters that need escaping inside a token are ~ and /:
- Escaping:
~→~0, then/→~1(the~replacement must happen first). - Unescaping reverses it:
~1→/, then~0→~.
Empty strings are valid tokens and pass through unchanged. These functions do not validate JSON Pointer syntax; they only handle token-level escaping.
escapeToken
Section titled “escapeToken”escapeToken(token): string encodes the special characters in a single
reference token so it is safe to use as a /-separated segment. Tokens with no
special characters are returned unchanged.
import { JsonPointer } from "effect"
JsonPointer.escapeToken("a/b") // => "a~1b"JsonPointer.escapeToken("c~d") // => "c~0d"JsonPointer.escapeToken("path/to~key") // => "path~1to~0key"JsonPointer.escapeToken("plain") // => "plain"unescapeToken
Section titled “unescapeToken”unescapeToken(token): string is the inverse of escapeToken, decoding escaped
sequences back to their original characters.
import { JsonPointer } from "effect"
JsonPointer.unescapeToken("a~1b") // => "a/b"JsonPointer.unescapeToken("c~0d") // => "c~d"JsonPointer.unescapeToken("path~1to~0key") // => "path/to~key"JsonPointer.unescapeToken("plain") // => "plain"Building and parsing a full pointer
Section titled “Building and parsing a full pointer”Because these functions work per-token, you map over the segments to build a
pointer and split-then-map to parse one back. This is the same idiom JsonPatch
uses internally to construct paths.
import { JsonPointer } from "effect"
// Build a JSON Pointer from path segments (note the special chars).const segments = ["users", "name/alias", "value"]const pointer = "/" + segments.map(JsonPointer.escapeToken).join("/")// => "/users/name~1alias/value"
// Parse a JSON Pointer back into its original segments.const tokens = pointer.split("/").slice(1).map(JsonPointer.unescapeToken)// => ["users", "name/alias", "value"]Reference
Section titled “Reference”JsonPatchOperation
Section titled “JsonPatchOperation”A single operation in a patch document. It is a discriminated union over the
op field, with three deterministic variants. Paths are JSON Pointers, value
is a Schema.Json value, and the optional description
field is free-form documentation.
import { JsonPatch } from "effect"
// add — insert a value. For arrays the last token may be "-" to append.const addOp: JsonPatch.JsonPatchOperation = { op: "add", path: "/users/-", value: { id: 1, name: "Alice" }}
// remove — delete the value at the location.const removeOp: JsonPatch.JsonPatchOperation = { op: "remove", path: "/users/0"}
// replace — overwrite the value at the location. Use "" for the root.const replaceOp: JsonPatch.JsonPatchOperation = { op: "replace", path: "/users/0/name", value: "Bob", description: "rename the first user"}JsonPatch
Section titled “JsonPatch”A complete patch document: ReadonlyArray<JsonPatchOperation>. Operations run
sequentially, so later ones observe the state produced by earlier ones. An empty
array is a no-op that returns the original document.
import { JsonPatch } from "effect"
const patch: JsonPatch.JsonPatch = [ { op: "add", path: "/items/-", value: "apple" }, { op: "replace", path: "/count", value: 5 }, { op: "remove", path: "/oldField" }]
const result = JsonPatch.apply(patch, { items: ["pear"], count: 3, oldField: "value"})// => { items: ["pear", "apple"], count: 5 }get(oldValue, newValue): JsonPatch computes a structural diff: the patch that
transforms oldValue into newValue. It returns an empty array for identical
values, recurses into nested objects and arrays, and emits a root replace when
the top-level values are different primitives.
Objects: keys are processed in sorted order, producing add for new keys,
remove for dropped keys, and recursive diffs for shared keys.
import { JsonPatch } from "effect"
const oldValue = { count: 1, name: "Alice" }const newValue = { active: true, count: 2 }
const patch = JsonPatch.get(oldValue, newValue)// => [// => { op: "add", path: "/active", value: true }, // new key// => { op: "replace", path: "/count", value: 2 }, // changed key// => { op: "remove", path: "/name" } // dropped key// => ]Arrays are compared by index. The shared prefix is diffed positionally, trailing removals are emitted highest-index-first, and trailing additions follow.
import { JsonPatch } from "effect"
const oldValue = { users: [{ id: 1, name: "Alice" }], count: 1 }const newValue = { users: [{ id: 1, name: "Bob" }, { id: 2, name: "Charlie" }], count: 2}
const patch = JsonPatch.get(oldValue, newValue)// => [// => { op: "replace", path: "/users/0/name", value: "Bob" },// => { op: "add", path: "/users/1", value: { id: 2, name: "Charlie" } },// => { op: "replace", path: "/count", value: 2 }// => ]apply(patch, oldValue): Schema.Json runs each operation in order and returns
the resulting document. It never mutates the input — affected arrays and objects
are copied. An empty patch returns the original reference, and a root replace
(path: "") returns the provided value directly.
import { JsonPatch } from "effect"
// Apply a generated patch (round-trips a diff).const before = { items: [1, 2, 3], total: 6 }const after = { items: [1, 2, 3, 4], total: 10 }JsonPatch.apply(JsonPatch.get(before, after), before)// => { items: [1, 2, 3, 4], total: 10 }
// Apply a hand-written patch.const document = { items: [1, 2, 3], total: 6 }const patch: JsonPatch.JsonPatch = [ { op: "add", path: "/items/-", value: 4 }, { op: "replace", path: "/total", value: 10 }]JsonPatch.apply(patch, document)// => { items: [1, 2, 3, 4], total: 10 }Out-of-bounds indices, missing properties, and unsupported root operations throw:
import { JsonPatch } from "effect"
JsonPatch.apply([{ op: "remove", path: "/missing" }], { a: 1 })// => throws: Property "missing" does not exist at "/missing".