Шесть типов, точность чисел и ловушки кодирования
Главное: В JSON всего шесть типов, но каждый из них способен незаметно изменить ваши данные. Эта статья за один проход разбирает ловушки чисел, строк и null.

Коротко: сначала проверьте, не выходят ли поля идентификаторов или денежных сумм за 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": "9007199254740993"}— меньше всего хлопот; - Разбирайте на сервере типами высокой точности:
BigInteger/BigDecimalв Java,json.Numberв Go,decimal.Decimalв Python; - На фронтенде используйте 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 (including zero-width joiners)
Когда нужен строгий подсчёт, используйте 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» и «значением по умолчанию».
Без согласованной конвенции появляются такие расхождения:
// 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 */ }
Рекомендация: в документации 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. Чек-лист, который можно применить сразу
- [ ] Все идентификаторы, денежные суммы и слишком большие целые явно объявлены строками в контракте API;
- [ ] В документации API описана семантика
nullв отличие от отсутствующего поля; - [ ] Ограничения длины строк уточняют, считаются кодовые точки или UTF-16-единицы;
- [ ] Перед сериализацией
Infinity/NaN/undefinedотфильтровываются или явно превращаются вnull; - [ ] JSON, встроенный в HTML, экранируется;
- [ ] У фронтенда и бэкенда есть по контрактному тесту на «большое целое + эмодзи + null».
Вопросы и ответы
Как обрабатывать целые числа больше 2^53 - 1?
Меньше всего хлопот — передавать такие поля, как идентификаторы, строками по всему конвейеру, например {"id": "9007199254740993"}. На сервере точность сохраняют BigInteger / BigDecimal в Java, json.Number в Go или decimal.Decimal в Python. На фронтенде парсер вроде 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».