DEV Community

Avinash Verma
Avinash Verma

Posted on AI-assisted

JSON Compare in JavaScript Without False Positives

Comparing two JSON documents looks easy until a harmless difference is reported as a breaking change, or a real contract change slips through unnoticed.

JSON.stringify(left) === JSON.stringify(right) is useful for a quick experiment, but it is rarely a complete comparison policy. Object property order, array semantics, missing values, and numeric normalization all need explicit decisions.

This article builds a small comparison function and shows where a general recursive algorithm should stop and application rules should begin.

Start with the behavior you need

Before writing code, decide what “equal” means for your data.

For many API payloads:

  • Object property order does not matter.
  • Array order does matter unless a specific path represents a set.
  • 1 and "1" are different values.
  • A missing property is different from a property set to null.
  • Duplicate array elements matter.

Those rules are reasonable defaults, but they are not universal. A list of permissions may behave like a set, while a sequence of transaction events must preserve order.

Why string comparison fails

These objects contain the same keys and values:

const first = { id: 7, active: true };
const second = { active: true, id: 7 };

console.log(JSON.stringify(first) === JSON.stringify(second));
// false
Enter fullscreen mode Exit fullscreen mode

The serialized strings differ because their properties were inserted in a different order. JSON objects are unordered collections by definition, so a structural comparison should compare keys by name.

String comparison also hides the reason for a mismatch. A useful comparison should report a path such as $.customer.address.postcode, not only false.

A path-aware recursive comparison

The following implementation preserves JavaScript type distinctions, ignores object-key order, preserves array order, and returns every mismatch it finds.

function compareJson(left, right, path = '$', differences = []) {
  if (Object.is(left, right)) {
    return differences;
  }

  const leftIsArray = Array.isArray(left);
  const rightIsArray = Array.isArray(right);

  if (leftIsArray || rightIsArray) {
    if (!leftIsArray || !rightIsArray) {
      differences.push({ path, left, right, reason: 'type changed' });
      return differences;
    }

    if (left.length !== right.length) {
      differences.push({
        path,
        left: left.length,
        right: right.length,
        reason: 'array length changed'
      });
    }

    const sharedLength = Math.min(left.length, right.length);
    for (let index = 0; index < sharedLength; index += 1) {
      compareJson(left[index], right[index], `${path}[${index}]`, differences);
    }

    return differences;
  }

  const leftIsObject = left !== null && typeof left === 'object';
  const rightIsObject = right !== null && typeof right === 'object';

  if (leftIsObject || rightIsObject) {
    if (!leftIsObject || !rightIsObject) {
      differences.push({ path, left, right, reason: 'type changed' });
      return differences;
    }

    const keys = new Set([...Object.keys(left), ...Object.keys(right)]);

    for (const key of keys) {
      const nextPath = `${path}.${key}`;
      const leftHasKey = Object.prototype.hasOwnProperty.call(left, key);
      const rightHasKey = Object.prototype.hasOwnProperty.call(right, key);

      if (!leftHasKey || !rightHasKey) {
        differences.push({
          path: nextPath,
          left: leftHasKey ? left[key] : undefined,
          right: rightHasKey ? right[key] : undefined,
          reason: leftHasKey ? 'property removed' : 'property added'
        });
        continue;
      }

      compareJson(left[key], right[key], nextPath, differences);
    }

    return differences;
  }

  differences.push({ path, left, right, reason: 'value changed' });
  return differences;
}
Enter fullscreen mode Exit fullscreen mode

Try it with a realistic API response:

const before = {
  id: 42,
  name: 'Ada',
  roles: ['reader', 'editor'],
  preferences: { theme: 'dark', alerts: true }
};

const after = {
  name: 'Ada',
  id: 42,
  roles: ['editor', 'reader'],
  preferences: { theme: 'light', alerts: true },
  verified: true
};

console.log(compareJson(before, after));
Enter fullscreen mode Exit fullscreen mode

The changed object-key order is ignored. The result reports the two reordered array positions, the theme change, and the new verified property.

Arrays need domain rules

Treating every array as unordered is dangerous. Sorting an event stream before comparing it would hide a sequencing regression.

Instead, allow unordered comparison only at paths where order has no meaning. For an array of unique primitive values, a simple helper is enough:

function samePrimitiveSet(left, right) {
  if (left.length !== right.length) return false;

  const leftSet = new Set(left);
  const rightSet = new Set(right);

  if (leftSet.size !== left.length || rightSet.size !== right.length) {
    throw new Error('Duplicate values require multiset comparison');
  }

  return leftSet.size === rightSet.size &&
    [...leftSet].every(value => rightSet.has(value));
}
Enter fullscreen mode Exit fullscreen mode

The duplicate check is deliberate. ["admin", "admin"] and ["admin"] should not become equal simply because both produce the same set.

Arrays of objects usually need a stable identity key. For example, compare users by id, then recursively compare the matching objects. If an item has no reliable identity, preserving order is the safer default.

Do not normalize types silently

It is tempting to convert values before comparing them:

String(1) === String('1'); // true
Enter fullscreen mode Exit fullscreen mode

That can hide an API contract change. A client expecting a number may fail when the server starts returning a string, even when both values look similar in a log.

Keep strict type comparison as the default. Add normalization only for a documented field where both representations are explicitly supported.

The same rule applies to dates. JSON has strings, not a native date type. Parse and compare date strings only at known date paths and only after deciding how to handle time zones and invalid values.

Missing, null, and undefined are different cases

Valid JSON can contain null, but it cannot contain JavaScript's undefined value. Inside application objects, however, undefined may appear before serialization.

These states should normally remain distinct:

{};
{ middleName: null };
{ middleName: '' };
Enter fullscreen mode Exit fullscreen mode

The first omits the field, the second explicitly declares no value, and the third supplies an empty string. APIs and validation layers often treat them differently.

The comparison function checks property ownership so a missing key is reported separately from a present key whose value is undefined.

Turn comparison rules into test cases

A comparison policy is easier to trust when its decisions appear as tests:

import assert from 'node:assert/strict';

assert.deepEqual(compareJson(
  { a: 1, b: 2 },
  { b: 2, a: 1 }
), []);

assert.equal(compareJson(
  { count: 1 },
  { count: '1' }
)[0].reason, 'value changed');

assert.equal(compareJson(
  { value: null },
  {}
)[0].reason, 'property removed');

assert.equal(compareJson(
  ['a', 'b'],
  ['b', 'a']
).length, 2);
Enter fullscreen mode Exit fullscreen mode

Add examples from your actual payloads: optional fields, identifiers, timestamps, empty collections, and large nested structures. These tests document the contract as clearly as the comparison code itself.

Inspect before automating

For one-off investigations, a visual structural diff is often faster than writing and debugging a custom script. I maintain JSONViewerTool, which includes a browser-based JSON comparison tool. I use the visual result to identify relevant paths, then encode the durable rules in tests like the ones above.

That disclosure matters: the linked tool is my project. The comparison principles and code in this article work independently of it.

The useful question is not “Are these strings equal?”

The useful question is: which differences matter for this payload?

A reliable JSON comparison strategy therefore has two layers:

  1. A strict structural default that respects types, missing keys, and array order.
  2. Small, explicit exceptions for paths with documented domain semantics.

Keeping those exceptions visible prevents convenient normalization from turning real contract changes into false negatives.

Top comments (0)