DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Structural JSON Diff From Scratch: Compare Meaning, Not Lines, and Emit an RFC 6902 Patch

A plain text diff treats JSON as lines of characters, so it lies about JSON. Reformat a file, reorder two keys, or add a trailing comma and it screams that everything changed — even though the data is identical. A structural diff parses both sides into real trees and compares meaning: order-insensitive for object keys, index-aligned for arrays, type-aware at every leaf. The whole thing is one small recursive function.

Type-aware equality

typeof alone isn't enough — typeof null and typeof [] both return "object". One helper returns the six JSON shapes so a shape change (the number 10 becoming the string "10") counts as a real change, not a coincidence.

function typeOf(v){
  if (v === null) return "null";
  if (Array.isArray(v)) return "array";
  return typeof v;                 // "object" | "string" | "number" | "boolean"
}
const MISSING = Symbol("missing");  // a key present on only one side
Enter fullscreen mode Exit fullscreen mode

The recursive core

Missing on one side is an add or a remove; different types is a change; same container type means recurse; same primitive means compare. Each call returns a tiny node recording its verdict.

function diffNode(a, b){
  if (a === MISSING) return { kind:"added",   value:b };
  if (b === MISSING) return { kind:"removed", value:a };
  const ta = typeOf(a), tb = typeOf(b);
  if (ta !== tb) return { kind:"changed", cont:false, before:a, after:b };
  if (ta === "object") return diffObject(a, b);
  if (ta === "array")  return diffArray(a, b);
  return a === b ? { kind:"unchanged", cont:false, before:a }
                 : { kind:"changed",   cont:false, before:a, after:b };
}
Enter fullscreen mode Exit fullscreen mode

Objects collect the union of keys from both sides into a set, so reordering keys is invisible and a key on only one side becomes an add or a remove. Arrays have no keys to match on, so the simple, predictable rule is to align position i to position i up to the longer length — honest, though it means an insert near the front cascades into a wall of "changed" (no move detection).

Name the location: JSON-Pointer

A path is just the trail of keys and indices you descended through. RFC 6901 joins them with / and escapes the two ambiguous characters: a literal / in a key becomes ~1, a literal ~ becomes ~0. The root is the empty string.

const escToken = t => String(t).replace(/~/g, "~0").replace(/\//g, "~1");
const pointer  = path => path.length ? path.map(escToken).map(t => "/" + t).join("") : "";
// ["meta","reviews"]  -> "/meta/reviews"
// ["paths","/api/v1"] -> "/paths/~1api~1v1"
Enter fullscreen mode Exit fullscreen mode

A diff you can execute

Once you know what changed and where, the labelled tree turns straight into an RFC 6902 JSON-Patch — a list of add / remove / replace ops any compliant library can apply to reproduce after from before. An added node is one add, a removed node one remove, a changed leaf one replace. A changed container emits nothing itself — you recurse so the ops land on the exact leaves that moved.

function buildPatch(node, path, ops){
  switch (node.kind){
    case "added":   ops.push({ op:"add",     path:pointer(path), value:node.value }); break;
    case "removed": ops.push({ op:"remove",  path:pointer(path) }); break;
    case "changed":
      if (node.cont) node.children.forEach(c => buildPatch(c.node, [...path, c.key], ops));
      else ops.push({ op:"replace", path:pointer(path), value:node.after });
      break;
    // "unchanged": emit nothing
  }
}
Enter fullscreen mode Exit fullscreen mode

Prove it with the assertions that separate structural from textual: diffNode({a:1,b:2}, {b:2,a:1}).kind === "unchanged" (key order ignored) and diffNode(10, "10").kind === "changed" (type matters). Because it compares meaning instead of characters, it shrugs off the whitespace and reorders that fool a line diff — the right way to compare data instead of the way it happens to be printed.

Paste two documents and watch the colour-coded tree and the emitted patch update live: https://dev48v.infy.uk/solve/day57-json-diff.html

Top comments (0)