JSON Toolkit

Шість типів, точність чисел і пастки кодування

Головне: У JSON лише шість типів, але кожен із них може непомітно змінити ваші дані. Ця стаття за один прохід розглядає пастки чисел, рядків і null.

Початковий~12 хв читанняОпубліковано 2026-10-30Оновлено 2026-10-30
Обкладинка статті «Шість типів, точність чисел і пастки кодування»

Коротко: спершу перевірте, чи поля ID або грошових сум перевищують 2^53 - 1 чи потребують багатьох знаків після коми, а потім — чи обмеження довжини рядків рахують кодові точки чи одиниці UTF-16; будь-яке значення поза діапазоном безпечних цілих передавайте рядком, а не сподівайтеся, що парсер збереже точність.

У JSON менше типів, ніж думає більшість, і більше пасток: числа зсуваються, рядки ламаються, а null — це зовсім інше, ніж відсутній ключ.

1. Огляд типів

Тип Приклад літерала Обмеження в JSON Типове відображення
Об'єкт {"a": 1} Ключі мають бути рядками, значення — будь-якими Object / Map / struct
Масив [1, "a", null] Елементи можуть бути різнотипними Array / List / slice
Рядок "text" UTF-8, подвійні лапки String
Число 1, -1.5, 1e9 Без провідних нулів, без +, без NaN double / Decimal
Булеве значення true / false Лише нижній регістр bool
null null Єдине значення null null / Option::None

Зверніть увагу на дві речі, яких не існує: немає типу дати й немає цілочисельного типу. Усі дати — рядки, а всі цілі та дробові значення поділяють єдиний тип «число».

2. Числа: подвійна точність — типова відповідь

2.1 Межа безпечних цілих чисел

Найбільше ціле число, яке подвійна точність IEEE 754 може подати точно, — 2^53 - 1 = 9007199254740991. Далі між сусідніми цілими з'являються проміжки:

JSON.parse('{"id": 9007199254740993}').id
// 9007199254740992

JSON.stringify({ id: 9007199254740993 })
// '{"id":9007199254740992}'

Це не проблема JSON, а проблема кроку «текст → подвійна точність». Будь-який конвеєр, де посередині є JavaScript (шлюз, BFF, фронтенд, сервіс Node), на неї натрапить.

Рішення (за спаданням моєї рекомендації):

  1. Використовуйте рядки для ID на всьому конвеєрі: {"id": "9007199254740993"} — найменше клопоту з усіх;
  2. Розбирайте на сервері типами високої точності: Java BigInteger / BigDecimal, Go json.Number, Python decimal.Decimal;
  3. На фронтенді використовуйте reviver у JSON.parse або парсер із підтримкою великих чисел (наприклад, json-bigint), щоб перетворити поле на BigInt, але пам'ятайте, що BigInt не можна передавати напряму в JSON.stringify.

2.2 Десяткові дроби не є точними значеннями

0.1 + 0.2            // 0.30000000000000004
JSON.stringify(0.1 + 0.2)
// "0.30000000000000004"

Завжди подавайте грошові суми цілими числами в найменшій одиниці валюти (копійках) або рядками; ніколи не накопичуйте їх із плаваючою точкою.

2.3 Пастки чисел під час серіалізації

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 і NaN мовчки перетворюються на null — це найважче виловити з класу «дані просто зникли».

2.4 Типова поведінка за мовами

Мова Типовий тип числа Випадки, що потребують явної обробки
JavaScript double Великі цілі, серіалізація BigInt
Go float64 (encoding/json) Використовуйте json.Number або Decoder.UseNumber(), щоб зберегти точність
Java (Jackson) int/long/double/BigDecimal, виведений із літерала Увімкніть DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS
Python int (довільна точність) / float Використовуйте parse_float=Decimal, щоб зберегти десяткову точність
Rust (serde_json) i64/u64/f64 Для довільної точності слід увімкнути arbitrary_precision

3. Рядки: потік байтів UTF-8, який читають крізь UTF-16

3.1 Довжина — це не те саме, що кількість символів

У JavaScript "🎉".length дорівнює 2, бо емодзі живуть у додаткових площинах і подаються сурогатною парою. Якщо сервер обмежує довжину за «кількістю символів», а фронтенд перевіряє через length, розбіжність гарантована.

'🎉'.length                 // 2
Array.from('🎉').length     // 1
[...'👨‍👩‍👧'].length           // 5 (включно з нульової ширини з'єднувачами)

Коли потрібен строгий підрахунок, використовуйте Intl.Segmenter (за графемними кластерами) або рахуйте кодові точки Unicode на бекенді та чітко описуйте визначення в документації API.

3.2 Одинарні сурогати

Одинарний високий сурогат, як-от "\ud800", граматично є валідним JSON (\uXXXX приймає будь-які чотири шістнадцяткові цифри), але це не валідний текст Unicode. Починаючи з ES2019 JSON.stringify екранує одинарні сурогати як \ud800, гарантуючи, що вивід є «коректно сформованим». Під час передачі між мовами такі значення відхилить інша сторона, якщо вона строго перевіряє Unicode.

3.3 \u2028 і \u2029

Роздільники рядків і абзаців валідні в JSON, але якщо вставити їх безпосередньо у вихідний код JavaScript або тег <script>, виникне синтаксична помилка. Вбудовуючи JSON у HTML, завжди екрануйте їх або переходьте на розбір із рядка через JSON.parse.

3.4 Три джерела «кракозябр» у китайському тексті

  1. Сам файл у GBK і читається як UTF-8;
  2. Заголовок відповіді оголошує charset=gbk, а вміст насправді в UTF-8;
  3. Він проходить через проміжне ПЗ, яке «декодує в рядок і знову кодує», тож китайський текст перекодовується вдруге.

Порядок розслідування: спершу подивіться на байти (xxd), потім на заголовок відповіді, і врешті на проміжне ПЗ.

4. null проти «цього ключа не існує»

У JSON ці два випадки різні:

{"nickname": null}
{}
  • Перший означає «поле існує, зі значенням порожньо»;
  • Другий означає «поле не існує».

У TypeScript це перетворюється на різницю між string | null і nickname?: string; у Java — на різницю між «явним null» і «значенням за замовчуванням». Без узгодженої конвенції з'являються такі розходження:

// Підхід фронтенду A
if (data.nickname === undefined) { /* вважати, що псевдоніма немає */ }
// Підхід фронтенду B
if (!data.nickname) { /* обробляти null і порожній рядок разом */ }

Рекомендація: документація API має заявити «коли значення відсутнє, використовуйте однаково null, ніколи не пропускайте ключ», або навпаки — але оберіть лише одне; оновлюючи API, використовуйте семантику PATCH, щоб уникнути двозначності «не надіслали чи надіслали null».

5. Масиви та об'єкти

5.1 Масиви не є «однорідними»

[1, "two", {"three": 3}, null]

Граматично валідно, але визначення типу стає дуже складним. В API намагайтеся зберігати елементи масиву однорідними; для різнотипних випадків переходьте на масив об'єктів із дискримінантним полем type.

5.2 Ключі об'єкта невпорядковані, але багато хто залежить від порядку

Специфікація каже, що об'єкт — це «невпорядкована колекція пар ім'я/значення». На практиці V8 зберігає порядок вставлення (крім цілочисельних ключів, які сортуються). Логіка, побудована на порядку ключів (наприклад, «взяти перший ключ»), означає залежність від деталі реалізації.

5.3 Розріджених масивів і порожнин не існує

JavaScript-масив [1, , 3] серіалізується в [1,null,3] — порожнина стає null. У JSON немає поняття «слота».

6. Чек-лист, за яким можна діяти

  • [ ] Усі ID, грошові суми та завеликі цілі числа явно є рядками в контракті API;
  • [ ] Документація API описує семантику null проти пропущеного поля;
  • [ ] Обмеження довжини рядків указують, чи рахуються кодові точки, чи одиниці UTF-16;
  • [ ] Перед серіалізацією відфільтровуються Infinity / NaN / undefined або явно перетворюються на null;
  • [ ] JSON, вбудований у HTML, екранується;
  • [ ] Фронтенд і бекенд мають по контрактному тест-кейсу, що покриває «велике ціле + емодзі + null».

Часті питання

Як обробляти цілі числа, більші за 2^53 - 1?

Найменш клопітний підхід — передавати такі поля, як ID, рядками на всьому конвеєрі, наприклад {"id": "9007199254740993"}. На сервері можна зберегти точність через Java BigInteger / BigDecimal, Go json.Number або Python decimal.Decimal. На фронтенді парсер на кшталт json-bigint може перетворити поле на BigInt, але пам'ятайте, що BigInt не можна передавати напряму в JSON.stringify.

Чому Infinity і NaN після серіалізації стають null?

Тип числа JSON не приймає нестандартних літералів на кшталт NaN та Infinity, тому JSON.stringify мовчки перетворює їх на null без жодної помилки. Це найважче виловити з класу «дані просто зникли». Відфільтруйте ці значення явно перед серіалізацією або перетворюйте їх на null згідно з бізнес-конвенцією та логуюте це.

Надсилати null чи взагалі пропускати поле?

Працює будь-який варіант, але документація API має обрати один і чітко його описати, інакше кожен фронтенд припускатиме щось інше. У прикладах {"nickname": null} і {} відображаються на string | null та nickname?: string у TypeScript. Оновлюючи API, використовуйте семантику PATCH, щоб уникнути двозначності «не надіслали чи надіслали null».