← Runnable Projects

阅读中文版本

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.

beginnerjavascriptunverified

Project

node-diff-two-json

Entry point: jsondiff.mjs

Install

node --version

Run

node jsondiff.mjs old.json new.json

Test

node --test jsondiff.test.mjs

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