Šest typů, přesnost čísel a úskalí kódování
Shrnutí: JSON má jen šest typů, ale každý z nich může vaše data tiše přetvořit. Tento článek v jednom průchodu pokrývá úskalí čísel, řetězců a null.

Stručně: nejprve zkontrolujte, zda pole s ID nebo peněžní částkou přesahují 2^53 - 1 nebo vyžadují mnoho desetinných míst, poté zkontrolujte, zda limity délky řetězců počítají kódové body, nebo jednotky UTF-16; každou hodnotu mimo rozsah bezpečných celých čísel přenášejte jako řetězec, místo abyste se spoléhali na to, že parser přesnost zachová.
JSON má méně typů, než si většina lidí myslí, a více úskalí: čísla se posunou, řetězce se rozbijí a null je něco jiného než chybějící klíč.
1. Přehled typů
| Typ | Příklad literálu | Omezení v JSON | Běžné mapování |
|---|---|---|---|
| Objekt | {"a": 1} |
Klíče musí být řetězce, hodnoty mohou být cokoli | Object / Map / struct |
| Pole | [1, "a", null] |
Prvky mohou být heterogenní | Array / List / slice |
| Řetězec | "text" |
UTF-8, dvojité uvozovky | String |
| Číslo | 1, -1.5, 1e9 |
Žádné úvodní nuly, žádné +, žádné NaN |
double / Decimal |
| Boolean | true / false |
Malá písmena | bool |
| null | null |
Jediná hodnota null | null / Option::None |
Všimněte si dvou věcí, které neexistují: neexistuje typ datum a neexistuje typ celé číslo. Všechna data jsou řetězce a všechna celá a desetinná čísla sdílejí jediný typ „number“.
2. Čísla: dvojitá přesnost je výchozí odpověď
2.1 Hranice bezpečných celých čísel
Největší celé číslo, které IEEE 754 s dvojitou přesností dokáže přesně reprezentovat, je 2^53 - 1 = 9007199254740991. Za touto hranicí se mezi sousedními celými čísly objevují mezery:
JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992
JSON.stringify({ id: 9007199254740993 })
// '{"id":9007199254740992}'
Není to problém JSON, je to problém kroku „text → dvojitá přesnost“. Narazí na něj každá pipeline, v níž je uprostřed JavaScript (brána, BFF, frontend, služba Node).
Řešení (seřazená podle toho, jak je doporučuji):
- Používejte řetězce pro ID v celé pipeline:
{"id": "9007199254740993"}, nejméně problémů ze všech; - Na serveru parsujte typy s vysokou přesností:
BigInteger/BigDecimalv Javě,json.Numberv Go,decimal.Decimalv Pythonu; - Na frontendu použijte reviver
JSON.parsenebo parser podporující velká čísla (napříkladjson-bigint) a převeďte pole naBigInt, pamatujte však, žeBigIntnelze předat přímo doJSON.stringify.
2.2 Desetinná čísla nejsou přesné hodnoty
0.1 + 0.2 // 0.30000000000000004
JSON.stringify(0.1 + 0.2)
// "0.30000000000000004"
Peněžní částky vždy reprezentujte jako celá čísla v nejmenší měnové jednotce (centech) nebo jako řetězce; nikdy je nesčítejte v plovoucí desetinné čárce.
2.3 Úskalí čísel při serializaci
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 a NaN se tiše změní na null, což je nejhůře dohledatelná třída „data prostě zmizela“.
2.4 Výchozí chování podle jazyka
| Jazyk | Výchozí číselný typ | Případy vyžadující explicitní ošetření |
|---|---|---|
| JavaScript | double | Velká celá čísla, serializace BigInt |
| Go | float64 (encoding/json) |
Použijte json.Number nebo Decoder.UseNumber() pro zachování přesnosti |
| Java (Jackson) | int/long/double/BigDecimal odvozené z literálu |
Vynuťte DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS |
| Python | int (libovolná přesnost) / float |
Použijte parse_float=Decimal pro zachování přesnosti desetinných čísel |
| Rust (serde_json) | i64/u64/f64 |
Libovolná přesnost vyžaduje zapnutí arbitrary_precision |
3. Řetězce: bajtový proud UTF-8 nazíraný přes UTF-16
3.1 Délka není totéž co počet znaků
V JavaScriptu je "🎉".length rovno 2, protože emoji žijí v doplňkových rovinách a jsou reprezentována surrogate párem.
Pokud server omezuje délku podle „počtu znaků“, zatímco frontend validuje pomocí length, obě strany se zaručeně neshodnou.
'🎉'.length // 2
Array.from('🎉').length // 1
[...'👨👩👧'].length // 5 (včetně spojek s nulovou šířkou)
Když potřebujete rigorózní počítání, použijte Intl.Segmenter (podle grapheme clusterů) nebo počítejte kódové body Unicode na backendu a definici výslovně uveďte v dokumentaci API.
3.2 Osamocené surrogate
Osamocený vysoký surrogate, například "\ud800", je gramaticky platný JSON (\uXXXX přijímá libovolné čtyři hexadecimální číslice), ale není to platný text Unicode.
Od ES2019 JSON.stringify escapuje osamocené surrogate jako \ud800, čímž zaručuje, že výstup je „well-formed“.
Při přenosu mezi jazyky budou takové hodnoty odmítnuty, pokud protější strana validuje Unicode přísně.
3.3 \u2028 a \u2029
Oddělovače řádku a odstavce jsou v JSON platné, ale jejich přímé vložení do zdrojového kódu JavaScriptu nebo do značky <script> způsobí syntaktickou chybu.
Při inline vkládání JSON do HTML je vždy escapujte, nebo přejděte k parsování ze řetězce pomocí JSON.parse.
3.4 Tři zdroje rozsypaného čínského textu
- Soubor je sám v GBK a čte se jako UTF-8;
- Hlavička odpovědi deklaruje
charset=gbk, ale obsah je ve skutečnosti UTF-8; - Data procházejí middlewarem, který „dekóduje na řetězec a poté znovu kóduje“, takže čínský text se překóduje podruhé.
Pořadí vyšetřování: nejprve se podívejte na bajty (xxd), poté na hlavičku odpovědi a nakonec na middleware.
4. null versus „tento klíč neexistuje“
V JSON jsou tyto dva případy rozdílné:
{"nickname": null}
{}
- První znamená „pole existuje, s prázdnou hodnotou“;
- Druhý znamená „pole neexistuje“.
V TypeScriptu se z toho stane rozdíl mezi string | null a nickname?: string; v Javě rozdíl mezi „explicitním null“ a „výchozí hodnotou“.
Bez dohodnuté konvence vznikají tato rozbití:
// Přístup frontendu A
if (data.nickname === undefined) { /* považovat za chybějící přezdívku */ }
// Přístup frontendu B
if (!data.nickname) { /* ošetřit null i prázdný řetězec společně */ }
Doporučení: dokumentace API má uvést „když hodnota chybí, používejte jednotně null, klíč nikdy nevynechávejte“, nebo opak – ale vyberte jen jednu variantu;
při úpravách API používejte sémantiku PATCH, abyste se vyhnuli dvojznačnosti „neposlali, nebo poslali null“.
5. Pole a objekty
5.1 Pole nejsou „homogenní“
[1, "two", {"three": 3}, null]
Gramaticky platné, ale definici typu je pak velmi obtížné napsat. V API se snažte udržet prvky pole homogenní; pro heterogenní případy přejděte k poli objektů s diskriminačním polem type.
5.2 Klíče objektů nejsou uspořádané, přesto na pořadí mnozí spoléhají
Specifikace říká, že objekt je „neuspořádaná kolekce dvojic název/hodnota“. V praxi V8 zachovává pořadí vkládání (kromě celočíselných klíčů, které se řadí). Stavět logiku na pořadí klíčů (například brát „první klíč“) znamená spoléhat na detail implementace.
5.3 Řídká pole a díry neexistují
JavaScriptové [1, , 3] se serializuje jako [1,null,3] – díra se změní na null. JSON pojem „slot“ nemá.
6. Kontrolní seznam, podle kterého můžete jednat
- [ ] Všechna ID, peněžní částky a příliš velká celá čísla jsou ve smlouvě API výslovně řetězci;
- [ ] Dokumentace API uvádí sémantiku
nulloproti vynechanému poli; - [ ] Limity délky řetězců uvádějí, zda počítají kódové body, nebo jednotky UTF-16;
- [ ] Před serializací odfiltrujte
Infinity/NaN/undefined, nebo je explicitně převeďte nanull; - [ ] JSON vložený do HTML je escapovaný;
- [ ] Frontend i backend mají shodný testovací případ pokrývající „velké celé číslo + emoji + null“.
Časté dotazy
Jak zacházet s celými čísly nad 2^53 - 1?
Nejmenší potíže způsobí posílat pole, jako jsou ID, jako řetězce v celé pipeline, například {"id": "9007199254740993"}. Na serveru můžete přesnost zachovat pomocí BigInteger / BigDecimal v Javě, json.Number v Go nebo decimal.Decimal v Pythonu. Na frontendu může parser jako json-bigint převést pole na BigInt, pamatujte však, že BigInt nelze předat přímo do JSON.stringify.
Proč se Infinity a NaN po serializaci změní na null?
Číselný typ JSON nepřijímá ne‑standardní literály jako NaN a Infinity, proto je JSON.stringify tiše převede na null bez jakékoli chyby. Je to nejhůře dohledatelná třída „data prostě zmizela“. Tyto hodnoty před serializací explicitně odfiltrujte, nebo je podle obchodní konvence převeďte na null a zaznamenejte to.
Má se posílat null, nebo pole úplně vynechat?
Funguje obojí, ale dokumentace API musí jednu variantu zvolit a jasně ji uvést, jinak každý frontend předpokládá něco jiného. V příkladech se {"nickname": null} a {} mapují na string | null a nickname?: string v TypeScriptu. Při úpravách API používejte sémantiku PATCH, abyste se vyhnuli dvojznačnosti „neposlali, nebo poslali null“.