Six types, number precision, and encoding pitfalls
Bottom line: JSON has only six types, but each of them can silently reshape your data. This article covers the pitfalls of numbers, strings, and null in one pass.

Bottom line: check first whether ID or money fields exceed 2^53 - 1 or need many decimal places of precision, then check whether string limits count code points or UTF-16 units; carry any value beyond the safe integer range as a string instead of trusting the parser to preserve precision.
JSON has fewer types than most people think, and more pitfalls: numbers shift, strings break, and null is a different thing from a missing key.
1. The type landscape
| Type | Literal example | Constraint in JSON | Common mapping |
|---|---|---|---|
| Object | {"a": 1} |
Keys must be strings, values can be anything | Object / Map / struct |
| Array | [1, "a", null] |
Elements may be heterogeneous | Array / List / slice |
| String | "text" |
UTF-8, double quotes | String |
| Number | 1, -1.5, 1e9 |
No leading zeros, no +, no NaN |
double / Decimal |
| Boolean | true / false |
Lowercase | bool |
| null | null |
The only null value | null / Option::None |
Note the two things that do not exist: there is no date type, and there is no integer type. All dates are strings, and all integers and floating-point values share the single "number" type.
2. Numbers: double precision is the default answer
2.1 The safe integer boundary
The largest integer IEEE 754 double precision can represent exactly is 2^53 - 1 = 9007199254740991. Beyond that, gaps appear between adjacent integers:
JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992
JSON.stringify({ id: 9007199254740993 })
// '{"id":9007199254740992}'
This is not a JSON problem; it is a problem with the "text → double precision" step. Any pipeline with JavaScript in the middle (gateway, BFF, frontend, Node service) will hit it.
Remedies (ordered by how much I recommend them):
- Use strings for IDs along the whole pipeline:
{"id": "9007199254740993"}, the least trouble of all; - Parse with high-precision types on the server: Java's
BigInteger/BigDecimal, Go'sjson.Number, Python'sdecimal.Decimal; - On the frontend, use the reviver of
JSON.parseor a parser that supports large numbers (such asjson-bigint) to turn the field intoBigInt, but note thatBigIntcannot be passed directly toJSON.stringify.
2.2 Decimals are not exact values
0.1 + 0.2 // 0.30000000000000004
JSON.stringify(0.1 + 0.2)
// "0.30000000000000004"
Always represent monetary amounts as integers in the smallest currency unit (cents) or as strings; never accumulate them with floating point.
2.3 Number pitfalls during serialization
JSON.stringify({ a: 1e21 }) // {"a":1e+21}
JSON.stringify({ a: -0 }) // {"a":0}
JSON.stringify({ a: Infinity }) // {"a":null}
JSON.stringify({ a: NaN }) // {"a":null}
JSON.stringify({ a: 10n }) // TypeError: Do not know how to serialize a BigInt
Infinity and NaN are silently turned into null, which is the hardest class of "the data just vanished" to track down.
2.4 Default behavior by language
| Language | Default number type | Cases that need explicit handling |
|---|---|---|
| JavaScript | double | Large integers, BigInt serialization |
| Go | float64 (encoding/json) |
Use json.Number or Decoder.UseNumber() to preserve precision |
| Java (Jackson) | int/long/double/BigDecimal inferred from the literal |
Force DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS |
| Python | int (arbitrary precision) / float |
Use parse_float=Decimal to preserve decimal precision |
| Rust (serde_json) | i64/u64/f64 |
Arbitrary precision requires enabling arbitrary_precision |
3. Strings: a UTF-8 byte stream viewed through UTF-16
3.1 Length is not the same as character count
In JavaScript, "🎉".length is 2, because emoji live in the supplementary planes and are represented by a surrogate pair.
If the server limits length by "character count" while the frontend validates by length, the two sides are guaranteed to disagree.
'🎉'.length // 2
Array.from('🎉').length // 1
[...'👨👩👧'].length // 5 (including zero-width joiners)
When you need rigorous counting, use Intl.Segmenter (by grapheme cluster) or count Unicode code points on the backend, and state the definition clearly in the API documentation.
3.2 Lone surrogates
A lone high surrogate such as "\ud800" is grammatically valid JSON (\uXXXX accepts any four hex digits), but it is not valid Unicode text.
Since ES2019, JSON.stringify escapes lone surrogates as \ud800, guaranteeing that the output is "well-formed".
When transferring across languages, values like this will be rejected if the other side validates Unicode strictly.
3.3 \u2028 and \u2029
Line separators and paragraph separators are valid in JSON, but embedding them directly into JavaScript source code or a <script> tag causes a syntax error.
When inlining JSON into HTML, always escape them or switch to parsing from a string with JSON.parse.
3.4 Three sources of garbled Chinese text
- The file itself is GBK and is read as UTF-8;
- The response header declares
charset=gbk, but the content is actually UTF-8; - It passes through a middleware that "decodes to a string and then encodes again", so the Chinese text is transcoded a second time.
Investigation order: look at the bytes first (xxd), then the response header, and finally the middleware.
4. null versus "this key does not exist"
In JSON these two are different:
{"nickname": null}
{}
- The former means "the field exists, with an empty value";
- The latter means "the field does not exist".
In TypeScript this becomes the difference between string | null and nickname?: string; in Java it becomes the difference between "explicit null" and "default value".
Without an agreed convention, these splits appear:
// Frontend A's approach
if (data.nickname === undefined) { /* treat it as having no nickname */ }
// Frontend B's approach
if (!data.nickname) { /* handle null and the empty string together */ }
Recommendation: the API documentation should state "use null uniformly when a value is absent, never omit the key", or the opposite — but pick only one;
when updating an API, use PATCH semantics to avoid the ambiguity of "was it not sent, or was it sent as null".
5. Arrays and objects
5.1 Arrays are not "homogeneous"
[1, "two", {"three": 3}, null]
Grammatically valid, but the type definition becomes very hard to write. In APIs, try to keep array elements homogeneous; for heterogeneous cases, switch to an array of objects with a type discriminant field.
5.2 Object keys are unordered, yet many people depend on order
The specification says an object is "an unordered collection of name/value pairs". In practice V8 preserves insertion order (except for integer keys, which get sorted). Building logic on key order (taking "the first key", for example) means depending on an implementation detail.
5.3 Sparse arrays and holes do not exist
JavaScript's [1, , 3] serializes to [1,null,3] — the hole becomes null. JSON has no concept of a "slot".
6. A checklist you can act on
- [ ] All IDs, monetary amounts, and oversized integers are explicitly strings in the API contract;
- [ ] The API documentation states the semantics of
nullversus an omitted field; - [ ] String length limits state whether they count code points or UTF-16 units;
- [ ] Filter out
Infinity/NaN/undefinedbefore serialization, or convert them tonullexplicitly; - [ ] JSON inlined into HTML is escaped;
- [ ] Frontend and backend each have a contract test case covering "large integer + emoji + null".
FAQ
How should I handle integers above 2^53 - 1?
The least troublesome approach is to send fields such as IDs as strings along the whole pipeline, for example {"id": "9007199254740993"}. On the server you can preserve precision with Java's BigInteger / BigDecimal, Go's json.Number, or Python's decimal.Decimal. On the frontend, a parser such as json-bigint can turn the field into BigInt, but remember that BigInt cannot be passed directly to JSON.stringify.
Why do Infinity and NaN become null after serialization?
The JSON number type does not accept non-standard literals such as NaN and Infinity, so JSON.stringify silently converts them to null without any error. This is the hardest class of "the data just vanished" to track down. Filter these values explicitly before serialization, or convert them to null per your business convention and log it.
Should I send null or omit the field entirely?
Either works, but the API documentation must pick one and state it clearly, otherwise frontends will each assume something different. In the examples, {"nickname": null} and {} map to string | null and nickname?: string in TypeScript. Use PATCH semantics when updating an API to avoid the ambiguity of "was it not sent, or was it sent as null".