JSON Toolkit

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.

Początkujący~12 min czytaniaOpublikowano 2026-10-30Zaktualizowano 2026-10-30
Okładka artykułu o sześciu typach, precyzji liczb i pułapkach kodowania

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

  1. Używaj łańcuchów dla identyfikatorów w całym potoku: {"id": "9007199254740993"}, najmniej kłopotów ze wszystkich;
  2. Parsuj na serwerze typami o wysokiej precyzji: BigInteger / BigDecimal w Javie, json.Number w Go, decimal.Decimal w Pythonie;
  3. Na frontendzie użyj revivera JSON.parse lub parsera obsługującego duże liczby (np. json-bigint), aby zamienić pole na BigInt, ale pamiętaj, że BigInt nie można przekazać bezpośrednio do JSON.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

  1. Plik sam jest w GBK, a jest odczytywany jako UTF-8;
  2. Nagłówek odpowiedzi deklaruje charset=gbk, ale treść faktycznie jest w UTF-8;
  3. 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ę null w stosunku do pominiętego pola;
  • [ ] Limity długości łańcuchów określają, czy liczą punkty kodowe, czy jednostki UTF-16;
  • [ ] Infinity / NaN / undefined są odfiltrowywane przed serializacją albo jawnie zamieniane na null;
  • [ ] 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”.