Sześć typów, precyzja liczb i pułapki kodowania
Podsumowanie: JSON ma tylko sześć typów, ale każdy z nich potrafi po cichu zmienić kształt danych. Ten artykuł omawia w jednym przebiegu pułapki liczb, łańcuchów i null.

Najważniejsze: najpierw sprawdź, czy pola identyfikatorów lub kwot przekraczają 2^53 - 1 albo wymagają wielu miejsc dziesiętnych, a potem sprawdź, czy limity łańcuchów liczą punkty kodowe czy jednostki UTF-16; każdą wartość poza zakresem bezpiecznych liczb całkowitych przenoś jako łańcuch, zamiast ufać parserowi w zachowanie precyzji.
JSON ma mniej typów, niż większość ludzi sądzi, i więcej pułapek: liczby się przesuwają, łańcuchy się psują, a null to coś innego niż brakujący klucz.
1. Krajobraz typów
| Typ | Przykład literału | Ograniczenie w JSON | Typowe odwzorowanie |
|---|---|---|---|
| Obiekt | {"a": 1} |
Klucze muszą być łańcuchami, wartości mogą być dowolne | Object / Map / struct |
| Tablica | [1, "a", null] |
Elementy mogą być niejednorodne | Array / List / slice |
| Łańcuch | "text" |
UTF-8, podwójne cudzysłowy | String |
| Liczba | 1, -1.5, 1e9 |
Bez wiodących zer, bez +, bez NaN |
double / Decimal |
| Wartość logiczna | true / false |
Małymi literami | bool |
| null | null |
Jedyna wartość null | null / Option::None |
Zauważ dwie rzeczy, których nie ma: nie ma typu daty i nie ma typu liczby całkowitej. Wszystkie daty to łańcuchy, a wszystkie liczby całkowite i zmiennoprzecinkowe dzielą jeden typ „number”.
2. Liczby: podwójna precyzja to domyślna odpowiedź
2.1 Granica bezpiecznych liczb całkowitych
Największa liczba całkowita, którą podwójna precyzja IEEE 754 potrafi przedstawić dokładnie, to 2^53 - 1 = 9007199254740991. Powyżej pojawiają się luki między sąsiednimi liczbami całkowitymi:
JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992
JSON.stringify({ id: 9007199254740993 })
// '{"id":9007199254740992}'
To nie jest problem JSON, a problem etapu „tekst → podwójna precyzja”. Trafi na niego każdy potok z JavaScriptem w środku (bramka, BFF, frontend, usługa Node).
Środki zaradcze (uporządkowane według tego, jak bardzo je polecam):
- Używaj łańcuchów dla identyfikatorów w całym potoku:
{"id": "9007199254740993"}, najmniej kłopotów ze wszystkich; - Parsuj na serwerze typami o wysokiej precyzji:
BigInteger/BigDecimalw Javie,json.Numberw Go,decimal.Decimalw Pythonie; - Na frontendzie użyj revivera
JSON.parselub parsera obsługującego duże liczby (np.json-bigint), aby zamienić pole naBigInt, ale pamiętaj, żeBigIntnie można przekazać bezpośrednio doJSON.stringify.
2.2 Ułamki dziesiętne nie są wartościami dokładnymi
0.1 + 0.2 // 0.30000000000000004
JSON.stringify(0.1 + 0.2)
// "0.30000000000000004"
Kwoty pieniężne zawsze przedstawiaj jako liczby całkowite w najmniejszej jednostce waluty (groszach) albo jako łańcuchy; nigdy nie gromadź ich za pomocą zmiennoprzecinkowych.
2.3 Pułapki liczb podczas serializacji
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 i NaN są po cichu zamieniane na null, co należy do najtrudniejszych do wytropienia przypadków „dane po prostu zniknęły”.
2.4 Domyślne zachowanie według języka
| Język | Domyślny typ liczby | Przypadki wymagające jawnej obsługi |
|---|---|---|
| JavaScript | double | Duże liczby całkowite, serializacja BigInt |
| Go | float64 (encoding/json) |
Użyj json.Number lub Decoder.UseNumber(), aby zachować precyzję |
| Java (Jackson) | int/long/double/BigDecimal wnioskowane z literału |
Wymuś DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS |
| Python | int (dowolna precyzja) / float |
Użyj parse_float=Decimal, aby zachować precyzję dziesiętną |
| Rust (serde_json) | i64/u64/f64 |
Dowolna precyzja wymaga włączenia arbitrary_precision |
3. Łańcuchy: strumień bajtów UTF-8 widziany przez UTF-16
3.1 Długość to nie liczba znaków
W JavaScript "🎉".length wynosi 2, ponieważ emoji żyją w płaszczyznach dodatkowych i są przedstawiane przez parę zastępczą.
Jeśli serwer ogranicza długość według „liczby znaków”, a frontend waliduje przez length, obie strony z pewnością się nie zgodzą.
'🎉'.length // 2
Array.from('🎉').length // 1
[...'👨👩👧'].length // 5 (including zero-width joiners)
Gdy potrzebne jest rygorystyczne liczenie, użyj Intl.Segmenter (według klastrów grafemów) albo licz punkty kodowe Unicode po stronie backendu, i jasno określ definicję w dokumentacji API.
3.2 Osamotnione surrogaty
Osamotniony wysoki surrogat, taki jak "\ud800", jest gramatycznie poprawnym JSON-em (\uXXXX przyjmuje dowolne cztery cyfry szesnastkowe), ale nie jest poprawnym tekstem Unicode.
Od ES2019 JSON.stringify escape'uje osamotnione surrogaty jako \ud800, gwarantując, że wyjście jest „poprawnie uformowane”.
Podczas transferu między językami takie wartości zostaną odrzucone, jeśli druga strona rygorystycznie waliduje Unicode.
3.3 \u2028 i \u2029
Separatory wierszy i akapitów są poprawne w JSON-ie, ale osadzenie ich bezpośrednio w kodzie źródłowym JavaScript lub w tagu <script> powoduje błąd składni.
Osadzając JSON w HTML, zawsze escape'uj je albo przełącz się na parsowanie z łańcucha za pomocą JSON.parse.
3.4 Trzy źródła chińskich krzaczków
- Plik sam jest w GBK, a jest odczytywany jako UTF-8;
- Nagłówek odpowiedzi deklaruje
charset=gbk, ale treść faktycznie jest w UTF-8; - Przechodzi przez middleware, które „dekoduje do łańcucha i koduje ponownie”, więc chiński tekst zostaje przetranskodowany drugi raz.
Kolejność śledztwa: najpierw obejrzyj bajty (xxd), potem nagłówek odpowiedzi, a na końcu middleware.
4. null versus „ten klucz nie istnieje”
W JSON-ie te dwa przypadki są różne:
{"nickname": null}
{}
- Pierwszy znaczy „pole istnieje, z pustą wartością”;
- Drugi znaczy „pole nie istnieje”.
W TypeScript staje się to różnicą między string | null a nickname?: string; w Javie różnicą między „jawnym null” a „wartością domyślną”.
Bez uzgodnionej konwencji pojawiają się takie rozjazdy:
// 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 */ }
Zalecenie: dokumentacja API powinna stwierdzać „gdy wartość jest nieobecna, używaj jednolicie null, nigdy nie pomijaj klucza” albo odwrotnie — ale wybierz tylko jedną zasadę;
podczas aktualizacji API używaj semantyki PATCH, aby uniknąć niejednoznaczności „czy nie wysłano, czy wysłano null”.
5. Tablice i obiekty
5.1 Tablice nie są „jednorodne”
[1, "two", {"three": 3}, null]
Gramatycznie poprawne, ale definicja typu staje się bardzo trudna do napisania. W API staraj się utrzymywać jednorodność elementów tablicy; w przypadkach niejednorodnych przejdź na tablicę obiektów z polem dyskryminatora type.
5.2 Klucze obiektu są nieuporządkowane, a mimo to wiele osób polega na kolejności
Specyfikacja mówi, że obiekt to „nieuporządkowany zbiór par nazwa/wartość”. W praktyce V8 zachowuje kolejność wstawiania (z wyjątkiem kluczy całkowitych, które są sortowane). Budowanie logiki na kolejności kluczy (np. „pierwszy klucz”) oznacza poleganie na szczególe implementacji.
5.3 Rzadkie tablice i dziury nie istnieją
[1, , 3] w JavaScript serializuje się do [1,null,3] — dziura staje się null. JSON nie ma pojęcia „slotu”.
6. Lista kontrolna do działania
- [ ] Wszystkie identyfikatory, kwoty pieniężne i nadmiernie duże liczby całkowite są jawnie łańcuchami w kontrakcie API;
- [ ] Dokumentacja API określa semantykę
nullw stosunku do pominiętego pola; - [ ] Limity długości łańcuchów określają, czy liczą punkty kodowe, czy jednostki UTF-16;
- [ ]
Infinity/NaN/undefinedsą odfiltrowywane przed serializacją albo jawnie zamieniane nanull; - [ ] JSON osadzany w HTML jest escape'owany;
- [ ] Frontend i backend mają wspólny przypadek testowy obejmujący „dużą liczbę całkowitą + emoji + null”.
FAQ
Jak radzić sobie z liczbami całkowitymi powyżej 2^53 - 1?
Najmniej kłopotliwe podejście to wysyłanie pól takich jak identyfikatory jako łańcuchów w całym potoku, na przykład {"id": "9007199254740993"}. Na serwerze możesz zachować precyzję za pomocą BigInteger / BigDecimal w Javie, json.Number w Go lub decimal.Decimal w Pythonie. Na frontendzie parser taki jak json-bigint potrafi zamienić pole na BigInt, ale pamiętaj, że BigInt nie można przekazać bezpośrednio do JSON.stringify.
Dlaczego Infinity i NaN stają się null po serializacji?
Typ liczby w JSON nie akceptuje niestandardowych literałów, takich jak NaN i Infinity, więc JSON.stringify po cichu zamienia je na null bez żadnego błędu. To najtrudniejszy do wytropienia przypadek „dane po prostu zniknęły”. Odfiltruj te wartości jawnie przed serializacją albo zamień je na null zgodnie z konwencją biznesową i zaloguj to.
Czy wysyłać null, czy całkowicie pominąć pole?
Jedno i drugie działa, ale dokumentacja API musi wybrać jedno i jasno to określić, inaczej każdy frontend przyjmie coś innego. W przykładach {"nickname": null} i {} odpowiadają w TypeScript string | null i nickname?: string. Podczas aktualizacji API używaj semantyki PATCH, aby uniknąć niejednoznaczności „czy nie wysłano, czy wysłano null”.