Deep JSON Diff Tool
A structural diff for two JSON documents reporting added, removed, and changed values as JSON Pointer paths, with type-change and array diffing covered.
Project
node-diff-two-json
Entry point: jsondiff.mjs
Install
node --versionRun
node jsondiff.mjs old.json new.jsonTest
node --test jsondiff.test.mjsExpected output
Given an old and new JSON document the CLI prints one diff entry per line with a JSON Pointer path and the nature of the change.Dependencies
- node >=20
What it does
Diffs two JSON documents structurally, reporting added, removed, changed, and type-changed values as RFC 6901 JSON Pointer paths.
import { readFileSync } from 'node:fs';
export function diffJson(a, b, path = '') {
const out = [];
if (a === b) return out;
const aObj = a !== null && typeof a === 'object';
const bObj = b !== null && typeof b === 'object';
if (!aObj || !bObj) {
out.push({ path: path || '/', type: 'changed', from: a, to: b });
return out;
}
const aIsArray = Array.isArray(a);
const bIsArray = Array.isArray(b);
if (aIsArray !== bIsArray) {
out.push({ path: path || '/', type: 'type-changed', from: a, to: b });
return out;
}
if (aIsArray) {
const shared = Math.min(a.length, b.length);
for (let i = 0; i < shared; i++) out.push(...diffJson(a[i], b[i], path + '/' + i));
for (let i = shared; i < a.length; i++) out.push({ path: path + '/' + i, type: 'removed', value: a[i] });
for (let i = shared; i < b.length; i++) out.push({ path: path + '/' + i, type: 'added', value: b[i] });
return out;
}
for (const key of Object.keys(a)) {
if (!(key in b)) out.push({ path: path + '/' + key, type: 'removed', value: a[key] });
else out.push(...diffJson(a[key], b[key], path + '/' + key));
}
for (const key of Object.keys(b)) {
if (!(key in a)) out.push({ path: path + '/' + key, type: 'added', value: b[key] });
}
return out;
}
export function formatDiff(diffs) {
return diffs.map((d) => {
if (d.type === 'changed') return d.path + ': ' + JSON.stringify(d.from) + ' -> ' + JSON.stringify(d.to);
if (d.type === 'added') return d.path + ': +' + JSON.stringify(d.value);
if (d.type === 'removed') return d.path + ': -' + JSON.stringify(d.value);
return d.path + ': type ' + d.type;
}).join('\n');
}
const [, , aPath, bPath] = process.argv;
if (aPath && bPath) {
const diffs = diffJson(JSON.parse(readFileSync(aPath, 'utf8')), JSON.parse(readFileSync(bPath, 'utf8')));
console.log(diffs.length ? formatDiff(diffs) : 'identical');
}
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { diffJson, formatDiff } from './jsondiff.mjs';
test('scalar change reports path and both values', () => {
const diffs = diffJson({ a: { b: 1 } }, { a: { b: 2 } });
assert.equal(diffs.length, 1);
assert.equal(diffs[0].path, '/a/b');
assert.equal(diffs[0].type, 'changed');
assert.equal(diffs[0].from, 1);
assert.equal(diffs[0].to, 2);
});
test('added and removed keys at the root and nested', () => {
const diffs = diffJson({ keep: 1, gone: 2 }, { keep: 1, fresh: 3, nested: { deep: true } });
assert.deepEqual(diffs.map((d) => d.type).sort(), ['added', 'added', 'removed']);
});
test('array diffs index removed and added tails', () => {
const diffs = diffJson([1, 2, 3], [1, 2, 4, 5]);
assert.equal(diffs.length, 2);
assert.equal(diffs[0].type, 'changed');
assert.equal(diffs[1].path, '/3');
assert.equal(diffs[1].type, 'added');
});
test('type change between object and array is one entry', () => {
const diffs = diffJson({ 0: 'a' }, ['a']);
assert.equal(diffs.length, 1);
assert.equal(diffs[0].type, 'type-changed');
});
test('formatting produces one readable line per diff', () => {
const text = formatDiff(diffJson({ x: 1 }, { x: 2 }));
assert.equal(text, '/x: 1 -> 2');
});
Run and verify
node --test jsondiff.test.mjs
node jsondiff.mjs old.json new.json
Run the tests for arrays and type changes, then diff an old and new config file from the CLI.
Expected Output
The test run should print five passing tests for scalar, structural, and type-change diffs. The CLI’s console.log output should print one diff entry per line as a JSON Pointer path plus the nature of the change. Diffing a deployment config where replicas grew from 2 to 4 and a tags array gained one element, the Output: is:
/replicas: 2 -> 4
/tags/2: +"canary"
Changed values render as path: from -> to, added values as path: +value, and removed values as path: -value; two identical documents print the single line identical.
Limitations
Arrays diff by index without move detection, so reordering a long array reports every shifted element as changed.