Большие целые в JSON и JavaScript: передаём ID без округления

Воспроизводим потерю 64-битного ID в JSON.parse, передаём число строкой и используем BigInt без попытки исправить уже округлённый Number.

Длинная цепочка за границей измерительной рамки как образ безопасной передачи больших целых чисел

Сервер отправил идентификатор, браузер разобрал ответ, а при следующей отправке несколько последних цифр изменились. Синтаксис JSON может быть корректным; причина в числовом типе получателя. Особенно легко потерять точность, если большое целое воспринимается как обычный JavaScript Number.

Безопасный диапазон целых Number ограничен величиной 2^53−1, то есть 9007199254740991. За этим пределом не все соседние целые различимы. MDN: Number.MAX_SAFE_INTEGER. Это свойство вычислительного представления, а не ограничение длины JSON-текста.

Воспроизводим ошибку получателя

Синтетический ID выбран только для демонстрации. Запустите в Node.js:

const source = '{"id":9223372036854775807}';
const parsed = JSON.parse(source);
console.log(Number.isSafeInteger(parsed.id));
console.log(JSON.stringify(parsed));
console.log(BigInt(parsed.id).toString());

Проверенный вывод:

false
{"id":9223372036854776000}
9223372036854775808

Последняя строка особенно показательна: преобразование уже разобранного Number в BigInt не восстановило исходное 9223372036854775807. Оно преобразовало приближённое значение. Разная десятичная запись во второй и третьей строках относится к представлению одного полученного числа; исходный ID уже потерян.

Не проверяйте точность сравнением с таким же большим числовым литералом в JavaScript. Сам литерал тоже мог округлиться. Для эталона используйте исходную строку цифр или корректно созданный BigInt.

Строка в транспортном контракте

Если ID не используется в арифметике, передавайте его строкой и сохраняйте строкой:

{"id":"9223372036854775807"}

Проверка сохранности:

const parsed = JSON.parse('{"id":"9223372036854775807"}');
if (parsed.id !== "9223372036854775807") throw new Error("ID changed");
console.log(JSON.stringify(parsed));

Результат точно содержит исходные цифры в кавычках. Это меняет тип поля, поэтому отправитель, получатель, схема и хранилище должны договориться о строковом ID. Нельзя исправлять чужое сообщение добавлением кавычек только в одном промежуточном скрипте и считать интеграцию завершённой.

Если нужны вычисления, преобразуйте проверенную строку в BigInt внутри приложения:

function unsignedInteger(text) {
  if (typeof text !== "string" || text.length === 0 || /\D/.test(text) ||
      (text.length > 1 && text[0] === "0")) {
    throw new Error("Expected canonical unsigned decimal digits");
  }
  return BigInt(text);
}
const idText = "9223372036854775807";
const next = unsignedInteger(idText) + 1n;
if (unsignedInteger("0") !== 0n) throw new Error("Zero rejected");
for (const invalid of ["", "01", "1\n", " 1", "-1", "1.0"]) {
  let rejected = false;
  try { unsignedInteger(invalid); } catch { rejected = true; }
  if (!rejected) throw new Error("Invalid integer accepted");
}
console.log(JSON.stringify({next: next.toString()}));

Проверенный вывод — {"next":"9223372036854775808"}. Учебное правило принимает каноническую запись неотрицательного целого: отдельный 0 разрешён, ведущие нули в остальных значениях запрещены. Проверка отвергает любой нецифровой символ, включая финальный LF. Встроенные отрицательные тесты также проверяют пробел, знак, дробь и пустую строку. Для ID с допустимыми ведущими нулями оставляйте строковый контракт: не удаляйте нули автоматически. Реальный контракт должен отдельно ограничить длину. Нативный JSON.stringify без специальных правил не сериализует BigInt напрямую: используйте согласованную транспортную форму. MDN: BigInt и JSON.

После перехода ID в строку проверьте сортировку и сравнение на всех уровнях. Лексикографический порядок строк может поставить "10" перед "2". Для непрозрачных идентификаторов это допустимо, если сортировка не используется как числовая; для последовательных номеров нужен явный порядок. Не возвращайте Number ради удобной сортировки большого ID. Проверяйте тип колонок хранилища, форм и экспортов: промежуточная таблица способна снова назначить строковому полю числовой тип.

Почему обычный reviver не спасает автоматически

Reviver может вызываться после преобразования числового токена в Number. Если взять его аргумент value и вызвать BigInt(value), точность может быть уже потеряна. В современных реализациях существуют возможности обращения к исходному тексту, но их доступность нужно проверять в конкретном runtime. Документация JSON.parse.

Для переносимого обмена проще явно описать строковое поле. При выборе библиотеки точного разбора проверьте её поведение на границе и на соседних значениях. Не вводите универсальное превращение всех JSON-чисел в строки: счётчик, измерение и ID могут иметь разные требования.

Какую роль играет Neraviko

Форматтер JSON Neraviko сохраняет запись числового токена. Он может показать, что исходный текст содержит нужные цифры, но не настраивает числовые типы браузера. Сохранение символов и безопасная передача через цепочку парсеров — разные проверки.

Преобразования Neraviko, которые декодируют JSON для другого формата, отклоняют небезопасные целые за пределами своего диапазона. Это защита от молчаливой потери, а не обещание автоматического строкового исправления. Для диагностического текста можно использовать синтаксический валидатор; для строкового контракта — JSON Schema.

Регрессия на границе

Проверяйте не только один большой ID. Добавьте максимальное безопасное значение, следующее за ним, несколько соседних 64-битных ID и строку с ведущими нулями, если они разрешены. Проверяйте полную цепочку: серверный ответ, клиентский разбор, сохранение и повторную отправку.

Сравнивайте исходные строковые идентификаторы, а не отображение округлённого числа. Не используйте реальные пользовательские ID в публичном примере. Neraviko отправляет ввод на сервер и ограничивает JSON-запрос 262 144 байтами с общими лимитами API. Для воспроизведения проблемы достаточно учебных значений из этой статьи.