Diff JSON by structure, not by lines
Why a text diff lies about reordered keys and pretty-printing, how a type change hides in a green line, and when to generate types from the payload you just compared.
· 5 min read
A text diff is the wrong instrument for JSON. It compares lines. JSON does not mean its line breaks. An object with the same keys in a different order, or the same document run through a formatter, becomes a page of red and green that describes the printer, not the data. The change you care about, a field that flipped from a number to a string, can sit inside a line the diff called a modification of the whole object.
The JSON diff walks the two values after parsing them. This note is how to read that result, and when the next step is the JSON to types generator rather than another visual pass.
Two documents that are not the same kind of different
Start with a payload a text diff will mangle:
{
"id": 42,
"active": true,
"plan": "pro",
"seats": 3
}
and the document a deploy produced:
{
"active": true,
"id": "42",
"plan": "pro",
"seats": 3,
"trial": false
}
A line diff reports that almost every line moved, because id and active swapped places, and then it may or may not make 42 versus "42" obvious. A structural diff reports two facts:
$.idchanged type, number to string.$.trialwas added, and its value isfalse.
Key order is not a third fact. JSON objects are unordered. A diff that reports order is answering a question the format does not ask, unless you are comparing the text on purpose, for a byte-for-byte fixture. Whitespace, indentation, and the space after a colon are the same kind of non-fact.
1 and 1.0 are the same number once parsed. "1" and 1 are not. That second case is the API regression that breaks a client which did typeof value === "number" or which decoded into an integer field. Call it a type change, not a small edit. The text looks almost identical. The types are not.
null and a missing key are also not the same. A missing key means the sender stopped including the field. null means the sender included it and had no value. Clients that use the key's presence to mean "the user set this" will treat those differently. A structural diff can show removed versus changed-to-null. A line diff shows a deleted line and, if you are unlucky, a nearby line that only changed its comma.
Arrays are where index comparison goes wrong
Objects have names. Arrays have positions. If you insert one element at the front of a list of users, a comparison by index says every later user changed. They did not. The list gained a head.
When the elements are objects with a stable id, compare them by that key. The inserted user is an addition. The others stay put even though their indexes moved. When the array is a set of tags, order is noise and you want a comparison that ignores it, while still noticing that a tag was duplicated or removed. When the array is a sequence, order is the data: steps in a job, points on a line. Ignoring order there hides the bug.
The tool cannot know which of those three you have. The path $.items[2].price is an index. If you meant "the item whose id is sku-2", say so by matching on id before you read the paths. Otherwise you will "fix" a price that belonged to a different row.
Empty arrays and arrays of mixed types need the same honesty. An empty array does not tell you the element type. A list that is sometimes a string and sometimes an object is a union, not a parse error you should smooth over.
What to ignore on purpose
API responses carry fields that change on every call and mean nothing for the comparison: requestId, traceId, generatedAt, server timing headers echoed into the body. If you leave them in, every diff is dirty and you stop reading it. Ignore those keys by name. Do not ignore a key because it is noisy this week if the noise is the product changing a price.
Large numbers are a separate trap. JSON has no integer type separate from number, and JavaScript cannot represent every integer above 2^53 exactly. A document that contains a snowflake id may already have been rounded before the diff runs, on either side, by whatever parsed it. If the id matters, the API should send it as a string. A diff that says the ids match can be two rounded values that match each other and match neither original.
After the diff, generate the types from the new shape
A structural diff tells you the contract moved. It does not update the structs in the client. Paste the new payload into JSON to types and read what the inference does with the same edges:
- A key present on some array elements and missing on others becomes optional. That is the right guess for a sample and the wrong guess if the server only omitted the key because those rows were old. Check it.
- A key that is
nullon one row and a string on the next becomes a nullable string, notany. - An empty array becomes an unknown element type. Inventing
string[]from[]would be a lie the sample does not support. - Nested objects become named types. A field that changed from a number to a string in the diff should show up as a string in the generated struct. If it shows up as a number, you generated from the old payload.
Go, Rust, Zod, and Pydantic do not share syntax, and they should not share a wrong nullability. Generate from the payload you just decided was the new contract, then edit the names. The generator has one sample. It cannot see the field you have not received yet.
When a text diff is the right tool
Use a line diff for prose, for source code, for a unified diff someone asked you to review, and for a JSON file whose bytes are the contract, such as a canonical form you hash before signing. The signature guide is that case: the body hash is of the bytes, so formatting is part of the signature, and a structural comparison would hide the byte change that made the signature fail.
Use the structural diff when you are asking "what data changed." Use types when you are asking "what do I have to declare now." Neither question is answered by a wall of green lines from a formatter.