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

Коротко: спершу перевірте, чи поля 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), на неї натрапить.
Рішення (за спаданням моєї рекомендації):
- Використовуйте рядки для ID на всьому конвеєрі:
{"id": "9007199254740993"}— найменше клопоту з усіх; - Розбирайте на сервері типами високої точності: Java
BigInteger/BigDecimal, Gojson.Number, Pythondecimal.Decimal; - На фронтенді використовуйте 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 Три джерела «кракозябр» у китайському тексті
- Сам файл у GBK і читається як UTF-8;
- Заголовок відповіді оголошує
charset=gbk, а вміст насправді в UTF-8; - Він проходить через проміжне ПЗ, яке «декодує в рядок і знову кодує», тож китайський текст перекодовується вдруге.
Порядок розслідування: спершу подивіться на байти (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».