Skip to content

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"

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"] }
  • 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, and replace subset of RFC 6902. There is no test, move, or copy.
  • get compares JSON structure, not domain meaning. It detects that a value changed, but does not infer semantic edits such as array moves.
  • apply never mutates its input. It copies only the containers it changes and returns a new value. An empty patch returns the original document reference.

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(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(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"

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"]

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"
}

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".