Skip to content
TwinScope0.3.10

JSON

A structural comparison, not a line diff. Reformat a file and nothing changes; reorder an array and it is still the same array. Five layouts, and everything normalisation hides is counted and one click from coming back.

GIF · press play
The normalisation rail. Every option re-runs the comparison, so the counts always describe the tree you are looking at.

Structural, not line-based#

A text diff of two JSON documents answers the wrong question. Run a formatter and every line changes; sort the keys and every line changes again — while the data is identical. TwinScope parses both sides and walks the two values together, so the answer is about the data.

before.jsonjson
{"name":"api","retries":3,"tags":["b","a"]}
after.jsonjson
{
"retries": 3,
"name": "api",
"tags": ["a", "b"]
}

Under the defaults that pair reports 0 changes: whitespace never existed as far as this engine is concerned, the two objects hold the same key set, and the two arrays hold the same items.

How each kind of value is compared#

ValueHow it is compared
ObjectsAs the union of both sides' keys. With ignore key order on, the keys are walked sorted, so declaration order carries no meaning
ArraysWith ignore array order on, items match by identity: an item present on both sides is unchanged wherever it moved to. Leftovers on each side pair up positionally, so an edited item reads as one change rather than a delete plus an unrelated add
ScalarsBy value. A difference becomes one changed row carrying before and after
A change of typeIts own row kind, with a note naming the transition — number → string
Missing vs presentAn absent key is an addition or a removal. A present null is not the same as an absent key, unless you say so

Every row carries a JSONPath, and container rows carry a badge summarising their subtree — 3 changed for an object, 4 → 6 items · 2 changed for an array.

RowGlyphMeaning
Added+Present only on the after side
RemovedPresent only on the before side
Changed~Same type, different value
Type changedThe type itself differs
IgnorednoneSuppressed by normalisation, shown with the reason
Click to enlarge
One row per path. The mode switcher is top-left; the normalisation rail and Explain are on the right.

The normalisation rail#

Four controls live in a panel beside the tree. Each one re-runs the comparison rather than filtering the view.

OptionDefaultWhat it does
Ignore key orderonObjects compare as sets of keys
Ignore nullsoffTreats null and a missing key as equal
Ignore array orderonMatches array items by identity, not by index
Ignored pathsnoneGlob paths whose differences are suppressed

Ignored paths are written the way you would say them — no $ root prefix:

PatternMatches
meta.requestIdExactly that path
*.updatedAtAn updatedAt under any single segment
orders[*].etagThe etag of every item in orders

* matches one path segment and [*] matches any array index. Everything else in a pattern is escaped before it becomes a regular expression, so a pattern can never be an injection.

Click to enlarge
Ignored paths are chips. Each one has an ✕, which is what makes suppression reversible rather than a setting you forgot you set.

Explain: what was hidden, and how to get it back#

Suppression that you cannot see is indistinguishable from a bug. So the panel ends with an Explain block that counts it — "4 of 19 differences were suppressed by normalisation" — followed by a Show them → button that clears the ignored paths and the null equivalence in one click, and a list naming every rule that was applied to this comparison.

When nothing was hidden it says so outright, rather than staying silent and leaving you to wonder.

Click to enlarge
The same notes travel into every export. A report can be read by someone who did not run the comparison.

Five ways to look at it#

A JSON comparison is a structural comparison whichever way you lay it out, and there are five layouts. Cycle them with ⌘\, or pick from the toolbar.

ModeWhat it shows
Side by sideTwo aligned columns of rows addressed by path. The default.
UnifiedOne column, before above after, like a patch
InlineOne column with the change marked inside the row
TreeNested containers with twisties — the shape of the document
RawThe two source documents, untouched

The mode is remembered per engine for the session, so the text diff and the JSON diff keep their own choice. That matters more than it sounds: changing a normalisation option re-runs the engine and remounts the view, and a layout that reset itself every time you toggled ignore nulls would read as a bug.

Reading the rows#

  • Only changes is on by default: unchanged leaves and whole unchanged subtrees disappear. Turn it off to see the document as a whole.
  • Expand all (Tree only) opens every container; the twisty on any container row collapses just that one.
  • Change navigation stops on every visible row that is not a container and is neither unchanged nor ignored — so what ‹ n/m › counts is what the current filters left on screen.

Search filters here#

The toolbar box is a filter for JSON, not a find: rows that do not match disappear. It matches case-insensitively against the key, the JSONPath, the values on both sides and any note on the row.

Ancestors of a match always survive, so a hit is never orphaned from its path — you see $ › orders › [2] › etag, not a lone etag with no context.

Copy a path or a value#

Right-click any row (or press the context-menu key, or ⇧F10) for three items:

  • Copy path — the JSONPath, e.g. $.user.roles[1]
  • Copy value (before)
  • Copy value (after)

A toast confirms which one you took. Object keys that are not identifiers are quoted in the path, so what you copy is always a valid JSONPath.

When the JSON does not parse#

A parse failure is a dead end for this engine, but not for the comparison. The error names the file and the reason, converts the byte offset into a line and column you can find in your editor, and offers Compare as text as a real button — one click and the same two files come back as a text diff.

Click to enlarge
An engine can offer a way out. The fallback travels with the error rather than being guessed at by the UI.

Limits#

LimitValueWhat happens past it
Depth100 levelsDeeper nodes get a row saying they were not compared
Nodes500,000The walk stops and a note says the rest is not shown
Array length10,000 itemsLonger arrays are matched by identity only, whatever the option says, and a note records it