JSON Toolkit

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

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

Начинающий~12 мин чтенияОпубликовано 2026-10-30Обновлено 2026-10-30
Обложка статьи «Шесть типов, точность чисел и ловушки кодирования»

Коротко: сначала проверьте, не выходят ли поля идентификаторов или денежных сумм за 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": "9007199254740993"} — меньше всего хлопот;
  2. Разбирайте на сервере типами высокой точности: BigInteger / BigDecimal в Java, json.Number в Go, decimal.Decimal в Python;
  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 (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 Три источника кракозябр в китайском тексте

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

Порядок расследования: сначала байты (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».