JSON.parse can silently turn 1e999 into null — check your formatter in 10 seconds
Here is a 10-second test you can run on any JSON formatter — including the one built into your own projects: {"amount": 1e999} Paste it in, format it, and look at what comes back. If the formatter's core is the
Here is a 10-second test you can run on any JSON formatter — including the one built into your own projects:
{"amount": 1e999}
Paste it in, format it, and look at what comes back. If the formatter's core is the default JavaScript round-trip — JSON.parse the text, then JSON.stringify the result to pretty-print it — the output is:
{"amount": null}
The amount is gone. Not flagged, not approximated — replaced with null, in output that looks completely normal. If that JSON was a payment payload, an invoice, or a config value, the corruption survives every downstream check that only asks "is this valid JSON?" It is valid. It's just wrong.
Why it happens
First, what the problem is not: it is not JSON. JSON itself can represent the original number exactly — 1e999 is a perfectly valid JSON number token, carried as text. The limitation is JavaScript's number representation, which is what the text gets converted into the moment it is parsed.
JavaScript numbers are IEEE 754 doubles. JSON.parse converts every JSON number into a double, and a double tops out around 1.8 × 10³⁰⁸. So:
JSON.parse('{"amount": 1e999}'); // { amount: Infinity }
JSON.stringify({ amount: Infinity }); // '{"amount":null}'
Infinity is a legal JavaScript number but it is not a legal JSON value, and JSON.stringify's defined behaviour for it is to emit null. Two reasonable, spec-compliant steps compose into silent data loss.
To be precise about where the ceiling sits: any parse-then-reserialize pipeline whose number type is an IEEE 754 double inherits the same limit, because the value is already lost at the moment of parsing — before formatting even begins. It is a property of the number representation, not of a particular library. Languages whose default integers are arbitrary-precision behave differently: Python's json module round-trips 12345678901234567890 exactly, because its integers never overflow.
It's a family of failures, not one
Overflow is the dramatic case, but the same pipeline quietly alters numbers in at least three other ways. All of these are the real outputs of JSON.parse → JSON.stringify in Node:
| Input | Output | What happened |
|---|---|---|
{"amount": 1e999} |
{"amount": null} |
Overflow → Infinity → serialized as null
|
{"amount": 1e-999} |
{"amount": 0} |
Underflow → flushed to zero |
{"id": 12345678901234567890} |
{"id": 12345678901234567000} |
Integer beyond double precision, rounded |
{"big": 9007199254740993} |
{"big": 9007199254740992} |
One past Number.MAX_SAFE_INTEGER — off by one |
The last two are the ones that should worry you most. An ID of 9007199254740993 coming back as 9007199254740992 is still a plausible-looking ID. It will match nothing, join to nothing, and produce no error anywhere. Large integer IDs — from databases, from Snowflake-style generators, from APIs that emit 64-bit values — cross the 2⁵³ boundary routinely.
How we found it: in our own tool
We found this class of bug the uncomfortable way: an independent audit of our own JSON formatter fed it {"amount": 1e999} and watched it print null. Our formatter was doing exactly what the default JSON.parse → JSON.stringify pipeline does, which is precisely the problem — "default" and "safe" are not the same thing here.
The fix we shipped is deliberately boring. Before parsing, the formatter scans the input's number literals (string-aware, so digits inside quoted text are ignored) and checks each one: if it would overflow to Infinity, underflow to zero, or lose integer precision, the tool refuses and names the literal:
The number "1e999" cannot be represented exactly (it is too large, and would be silently replaced by null). The input was left unchanged — quote the number as a string if it must pass through as text.
Refusing is a design choice, not the only defensible one. Alternatives a tool (or your own code) can use:
- Preserve the literal: keep numbers as their original text through the pipeline (several parsers offer this; in JavaScript you can do it with a reviver plus a lossless-JSON library).
- Parse as BigInt / decimal when the domain is money or 64-bit IDs.
- Refuse loudly (what we chose): appropriate when the tool's job is formatting, not transformation — the user should decide how an unrepresentable number is handled, not the formatter.
What is not defensible is the default: silently emitting a different number — or null — and letting the user copy it away.
The takeaway test
Keep these three lines next to your desk and run them against any tool that touches JSON — formatters, validators, converters, and your own services:
{"amount": 1e999}
{"id": 12345678901234567890}
{"big": 9007199254740993}
If the output differs from the input and nothing told you, you've found the same bug we did.
Disclosure: I run DevKitTools, a free set of browser-based developer tools. Its JSON formatter implements the "refuse loudly" behaviour described above — you can verify that with the same three test lines. Everything else in this article is reproducible with the Node commands shown.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.