Writing a compliant RFC 6902 JSON Patch CLI in one evening, zero runtime deps

typescript dev.to

Writing a compliant RFC 6902 JSON Patch CLI in one evening, zero runtime deps

json-patch apply config.json changes.patch.json — a TypeScript CLI that implements RFC 6902 JSON Patch by hand, including the six ops, RFC 6901 pointer escaping, and a structural differ whose output round-trips. Ships compiled JS with zero runtime dependencies.

Most people know JSON Patch as "the thing Kubernetes uses to describe object mutations over the wire." It's also the answer to a problem I keep hitting on smaller boxes: I have a JSON config file, I want to change one field in it from a shell script, and I want the change to fail loudly if the current state doesn't match what I expected. jq can do the mutation, but its expression language is ad-hoc and it won't guard me against a concurrent edit. A JSON Patch with a test op up front does exactly what I want — and the standard is small enough that you can implement it from the RFC in one evening.

So I did. json-patch-cli is a TypeScript CLI that ships compiled JS with zero runtime dependencies, implements all six RFC 6902 ops by hand on top of an RFC 6901 pointer layer, and round-trips through a hand-written differ. 40 tests, three source files, one Dockerfile.

What RFC 6902 actually says

Once you strip out the JSON schema and the HTTP media-type negotiation, the spec is tiny. A JSON Patch is a JSON array of operation objects. Each operation has an op field, and the valid values are exactly six: add, remove, replace, move, copy, test. Every op except the root add takes a path expressed as an RFC 6901 JSON Pointer. move and copy additionally take a from pointer. test and the value-carrying ops (add, replace) carry a value field.

The behaviour of each op is described in a page or two of prose, and the conformance test suite in Appendix A is a dozen-ish concrete examples. That's the entire spec surface. If your implementation handles Appendix A plus a small cluster of edge cases around ~0/~1 escaping and the - array index, you are compliant.

The interesting part is that three of the six ops are defined in terms of each other. replace is "a remove followed by an add." move is "a remove at from followed by an add at path, but the from pointer must not be a prefix of path." copy is "a duplicate of the value at from, added at path." So if you write add and remove correctly, you can get replace, move, and copy for free with a few lines of composition. That's what I did.

The pointer layer

RFC 6901 (JSON Pointer) is the thing people most often mess up. The format looks harmless: /foo/bar/0 walks into {"foo": {"bar": ["first", ...]}}. The catch is the escape sequences for / and ~ inside key names: ~1 stands for /, and ~0 stands for ~. And the unescape order matters.

// RFC 6901 §4: decode ~1 first, then ~0.
export function unescapeToken(token: string): string {
  return token.replace(/~1/g, '/').replace(/~0/g, '~');
}
Enter fullscreen mode Exit fullscreen mode

Why does the order matter? Consider the encoded token ~01. If you decode ~0 first, you get ~1, then the second pass turns that into /, which is wrong — the original key was ~1, a tilde followed by the digit one. Decoding ~1 first doesn't match (~0 is a tilde, not a slash), and the second pass correctly turns ~0 into ~, leaving ~1. The RFC pins down this order explicitly, and every non-obvious bug I've seen in a pointer implementation has come from reversing it.

I cover this case in a single test:

it('unescapes ~1 and ~0 in the correct order', () => {
  expect(unescapeToken('~1')).toBe('/');
  expect(unescapeToken('~0')).toBe('~');
  // ~01 must decode to ~1 (tilde followed by one), NOT to /
  expect(unescapeToken('~01')).toBe('~1');
});
Enter fullscreen mode Exit fullscreen mode

The resolver is a straightforward reduce that walks one segment at a time, with one gotcha for arrays: the token - is legal and refers to "one past the end of the array" — but only for add operations. Every other op must reject it. I expressed that as an allowDash flag on the index parser so the caller's intent is visible at the call site.

export function parseArrayIndex(token: string, length: number, allowDash: boolean): number {
  if (token === '-') {
    if (!allowDash) throw new PointerError(`'-' is only valid for add operations`);
    return length;
  }
  if (!/^(0|[1-9][0-9]*)$/.test(token)) {
    throw new PointerError(`invalid array index '${token}'`);
  }
  const index = Number(token);
  if (index > length) {
    throw new PointerError(`array index ${index} out of bounds (length ${length})`);
  }
  return index;
}
Enter fullscreen mode Exit fullscreen mode

The regex is there because the RFC forbids leading zeros (01 is not a valid index) and forbids any non-digit characters. A lot of naive implementations use parseInt and skip these checks, which means /foo/01 and /foo/0xff silently resolve to index zero. That is worth one extra line of regex to get right.

The six operations

With the pointer layer in place, the op implementations are almost transcriptions of the English in §4 of the RFC. Here is add:

function addOp(doc: JsonValue, path: string, value: JsonValue): JsonValue {
  if (path === '') return value;           // add at root = replace the doc
  const { parent, token } = resolveParent(doc, path);
  if (Array.isArray(parent)) {
    const idx = parseArrayIndex(token, parent.length, true);  // allow '-'
    parent.splice(idx, 0, value);
  } else if (parent !== null && typeof parent === 'object') {
    (parent as Record<string, JsonValue>)[token] = value;
  } else {
    throw new PatchError(`add: parent is a primitive`);
  }
  return doc;
}
Enter fullscreen mode Exit fullscreen mode

The only subtlety is that RFC 6902 distinguishes "add at path when path is empty" (which replaces the whole document) from "add at path when path points at a nested target." The empty case needs to short-circuit before we try to resolveParent, because the root has no parent.

remove is symmetric, but it has to reject the empty path — you can't remove the root of a document and have anything sensible left over. replace is also symmetric but requires that the target already exist.

move is where the composition shines:

function moveOp(doc: JsonValue, from: string, path: string): JsonValue {
  if (isPrefix(from, path)) {
    throw new PatchError(`move: from '${from}' is a prefix of path '${path}'`);
  }
  const value = resolvePointer(doc, from);
  const removed = clone(value);
  doc = removeOp(doc, from);
  return addOp(doc, path, removed);
}
Enter fullscreen mode Exit fullscreen mode

The RFC-mandated check — "The from location MUST NOT be a proper prefix of the path location" — is the part that trips up composed implementations. Without it, you could move /a → /a/b, which would first remove /a and then try to add to a pointer whose parent no longer exists. The check costs three lines.

copy is the same pattern with the remove step removed. test is the simplest of all: resolve the pointer, deep-compare the result to the value, and either return the document unchanged or throw.

export function testOp(doc: JsonValue, path: string, value: JsonValue): JsonValue {
  const actual = path === '' ? doc : resolvePointer(doc, path);
  if (!jsonEqual(actual, value)) {
    throw new PatchError(`test: value at '${path}' does not match`);
  }
  return doc;
}
Enter fullscreen mode Exit fullscreen mode

The deep-compare function, jsonEqual, handles the one thing JavaScript gets wrong for JSON: object key order. JSON objects are unordered; {"a":1,"b":2} and {"b":2,"a":1} are the same document. === doesn't know that, and JSON.stringify sort-of does but depends on insertion order. So I wrote the comparator explicitly:

if (typeof a === 'object') {
  if (typeof b !== 'object' || Array.isArray(b)) return false;
  const ak = Object.keys(a);
  const bk = Object.keys(b as Record<string, JsonValue>);
  if (ak.length !== bk.length) return false;
  for (const k of ak) {
    if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
    if (!jsonEqual((a as Record<string, JsonValue>)[k]!, (b as Record<string, JsonValue>)[k]!))
      return false;
  }
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Iterate one object's keys, check each exists in the other, recurse on the value. Array comparisons use strict positional equality, which is also what the RFC mandates.

The differ, and why it doesn't try to be minimal

Going the other direction — "give me a patch that turns a into b" — is harder than applying one, because there is no unique answer. For [1,2,3] vs [1,3], you could emit remove /1 or remove /2 followed by replace /1 3, or a hundred other variants. A minimal diff in the sense of "smallest number of ops" is an LCS problem for arrays, and it's NP-ish for nested structures. Real-world JSON differs don't try to be minimal. They just produce something that round-trips.

My differ has the same property. It walks the two documents in lockstep: if the types match and they are containers, it recurses; otherwise it emits a replace at the current path. Array growth uses add /-, array shrinkage walks from the tail to keep earlier indices valid, object removals come before adds for the same reason.

export function diffJson(a: JsonValue, b: JsonValue, basePath = ''): Operation[] {
  if (jsonEqual(a, b)) return [];
  if (
    a === null || b === null ||
    typeof a !== typeof b ||
    Array.isArray(a) !== Array.isArray(b) ||
    typeof a !== 'object'
  ) {
    return [{ op: 'replace', path: basePath, value: clone(b) }];
  }
  if (Array.isArray(a) && Array.isArray(b)) return diffArray(a, b, basePath);
  return diffObject(a as any, b as any, basePath);
}
Enter fullscreen mode Exit fullscreen mode

The test suite includes a round-trip property: given any a and b, applyPatch(a, diffJson(a, b)) must deep-equal b. I run this against objects-with-arrays, pure arrays of varying lengths, type-changes at the leaf, and — the tricky case — keys containing / and ~. That last one matters because if you forget to escape while building pointer strings, keys with slashes turn into nested traversals, and the round-trip fails in a way that's hard to debug.

The CLI surface

The commands are apply, diff, and test. Flags use node:util.parseArgs, which has been in Node stable for a while now and removes the last excuse for pulling in yargs. test is its own command, not a flag, because in shell scripts I want to exit cleanly on just the guards:

json-patch test state.json guard.patch.json && do_risky_edit
Enter fullscreen mode Exit fullscreen mode

Under the hood, test filters the patch to op === 'test' and runs each one, printing ok or FAIL with the path. It exits 0 iff every test passes, 1 if any fail, 2 on usage errors. That matches the shell-script expectation: && means "only continue if the guard succeeded."

apply takes --in-place to write back to the source file, and --indent N / --compact for output formatting. diff is the inverse of apply and produces a patch on stdout that you can pipe into a file, version-control, and replay later.

Tests and Docker

40 tests across two files. The pointer tests cover RFC 6901 appendix B almost verbatim (including the weird keys like c%d, g|h, k"l, m~n). The patch tests cover each op in isolation, each RFC 6902 Appendix A example, the differ round-trip property, and a couple of error cases (unknown ops, prefix-move, missing keys on replace).

The Dockerfile is a multi-stage Alpine build: the first stage installs dev-deps and compiles TypeScript, the second copies only the dist/ directory into a fresh node:20-alpine, drops privileges to a non-root app user, and runs node /app/dist/cli.js as the entrypoint. The final image has no node_modules at all — the TypeScript compilation is the only thing npm is needed for, and once the JS is built we don't need it again.

FROMnode:20-alpineASbuilder
WORKDIR /build
COPY package.json package-lock.json* ./
RUN npm install --no-audit --no-fund
COPY tsconfig.json vitest.config.ts ./
COPY src ./src
COPY tests ./tests
RUN npm run build

FROMnode:20-alpineASruntime
WORKDIR /app
COPY --from=builder /build/dist ./dist
COPY package.json LICENSE ./
RUN addgroup -S app && adduser -S app -G app && chown -R app:app /app
USER app
ENTRYPOINT ["node", "/app/dist/cli.js"]
Enter fullscreen mode Exit fullscreen mode

What I left out

No JSON Merge Patch (RFC 7396). It's a different spec and it genuinely is simpler — just shallow object merging — but conflating the two would have muddied the codebase. If I wanted merge-patch support, I'd add a merge subcommand and a separate module.

No schema validation of the patch file. I trust the JSON parser to give me a well-formed value and I trust the op dispatcher to throw on anything that doesn't fit the Operation union. A stricter validator would be straightforward but wouldn't catch anything my existing error messages don't already surface.

No streaming. Every op clones the entire document up front so that a mid-patch failure leaves the caller's value untouched. For config files that's fine. For multi-megabyte JSON you'd want something different, but multi-megabyte JSON is also exactly the case where JSON Patch is not the right tool.

Lessons from this entry

  1. Implementing a small RFC from the spec is a surprisingly good way to learn it. I'd used JSON Patch plenty before without really thinking about pointer escape order or the prefix-move rule.
  2. Composition buys you a lot. move, copy, and replace are all trivial once add and remove are correct. The whole patch module is under 250 lines.
  3. Appendix examples are worth their weight in gold. RFC 6902 Appendix A is basically a free test suite. Any implementation that passes it is almost certainly correct on the happy path.
  4. node:util.parseArgs is good now. Once you've used it a few times there's no reason to reach for yargs on a single-purpose CLI.

Try it

git clone https://github.com/sen-ltd/json-patch-cli.git
cd json-patch-cli
npm install
npm run build
node dist/cli.js --help
Enter fullscreen mode Exit fullscreen mode

Or Docker:

docker build -t json-patch-cli .
docker run --rm -v "$PWD":/work -w /work json-patch-cli apply config.json patch.json
Enter fullscreen mode Exit fullscreen mode

Source is MIT. Happy patching.

Source: dev.to

arrow_back Back to Tutorials