JSON Toolkit

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.

Beginner~12 min readPublished 2026-10-30Updated 2026-10-30
Cover for Six types, number precision, and encoding pitfalls

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):

  1. Use strings for IDs along the whole pipeline: {"id": "9007199254740993"}, the least trouble of all;
  2. Parse with high-precision types on the server: Java's BigInteger / BigDecimal, Go's json.Number, Python's decimal.Decimal;
  3. On the frontend, use the reviver of JSON.parse or a parser that supports large numbers (such as json-bigint) to turn the field into BigInt, but note that BigInt cannot be passed directly to JSON.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

  1. The file itself is GBK and is read as UTF-8;
  2. The response header declares charset=gbk, but the content is actually UTF-8;
  3. 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 null versus an omitted field;
  • [ ] String length limits state whether they count code points or UTF-16 units;
  • [ ] Filter out Infinity / NaN / undefined before serialization, or convert them to null explicitly;
  • [ ] 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".