JSONの大きな数値IDが末尾ずれる原因と対処|JavaScriptで16桁超が丸められる理由
APIから受け取ったJSONの数値IDが、JSON.parse した途端に末尾だけ 000 に変わっている。桁数は同じなので気づきにくく、「IDは合っているのに検索しても出てこない」という形で発覚します。
結論から言うと、JavaScriptの数値は倍精度浮動小数点で、整数を正確に持てるのは 9,007,199,254,740,991(2の53乗−1)までです。JSONの仕様には桁数の上限がなく、これを超える整数は「例外も出ないまま近い値に丸められる」ことになります。直し方は、IDを文字列で受け渡すのが基本です。この記事では Node.js v20.20.1 で実測した結果を示しながら、原因・影響範囲・直し方を整理します。
何が起きているのか:実測
次のように、数値をそのまま JSON.parse して String() で戻した結果です(Node.js v20.20.1 で実測)。
| 元のJSONの数値 | 戻した結果 | 変わったか |
|---|---|---|
| 9007199254740991 | 9007199254740991 | 変わらない |
| 9007199254740992 | 9007199254740992 | 変わらない |
| 9007199254740993 | 9007199254740992 | 変わる(1ずれる) |
| 9007199254740995 | 9007199254740996 | 変わる |
| 12345678901234567 | 12345678901234568 | 変わる |
| 123456789012345678 | 123456789012345680 | 変わる |
| 6476060033855273896 | 6476060033855274000 | 変わる(末尾が 000) |
| 1700000000123456789 | 1700000000123456800 | 変わる |
| 1700000000123 | 1700000000123 | 変わらない |
末尾が 000 や 00 になるのは、JavaScriptが「同じ値を表せる最短の桁数」で数値を表示するためです。実際に内部で持っている値は 6476060033855273984 で、元の ...3896 とは違います(BigInt(n) で確認しました)。
別のIDが同じ値になる
2の53乗を超えると、連続する整数を区別できなくなります。9007199254740992 と 9007199254740993 は、解析すると同じ値になりました(=== が true)。2つの別のIDが、解析後は同一のIDとして扱われるということです。
何桁から危ないのか:ランダムな整数で実測
「16桁を超えたら全部ずれる」わけではありません。各桁数のランダムな整数を10万個ずつ作り、JSON.parse して文字列に戻したときに元と変わった割合を測りました(Node.js v20.20.1、1回の計測)。
| 桁数 | 元と変わった割合 |
|---|---|
| 15桁 | 0% |
| 16桁 | 5.43% |
| 17桁 | 83.68% |
| 18桁 | 98.31% |
| 19桁 | 99.82% |
16桁で割合が低いのは、16桁の整数の大半が 9,007,199,254,740,991 以下に収まるためです。この値以下の 88,940 個は変わったものが0個でした。
つまり、16桁のIDは「たまたま通る」ことがあり、17桁以上はほぼ確実に壊れます。 16桁で動いていたシステムが、桁が増えた途端に壊れることもありえます。
実務上の落とし穴:調査に使った整形ツールがデータを書き換える
ここからは、あまり書かれていない落とし穴です。原因調査のために、JSONを整形ツールに貼って見た時点で、すでに値が書き換わっていることがあります。
当サイトのJSON整形ツールは、ソースを確認すると、整形・圧縮のどちらも JSON.parse してから JSON.stringify で書き出す作りです。同じ処理を再現して試したところ、次のようになりました。
{"id":6476060033855273896,"name":"a"}
{
"id": 6476060033855274000,
"name": "a"
}
さらに、「検証」(解析だけ行う処理)はエラーにならず、正常と判断されます。 JSONとして正しいのは事実なので当然ですが、「検証OK・整形後に値が変わっている」ことに気づかないまま、整形後のテキストを再利用すると事故になります。これは当サイトのツールに限った話ではなく、ブラウザの JSON.parse で解析して書き出す整形ツール全般に当てはまると考えられます(他社ツールの実装は確認していません)。
整形で変わるのは大きな整数だけではありません。同じ処理で、次のような書き換えも確認しました。
| 入力 | 整形・圧縮後 | 内容 |
|---|---|---|
1.0、1.50 | 1、1.5 | 末尾の0が消える |
1e2 | 100 | 指数表記が展開される |
1e400 | null | 範囲外の数値が null になる |
{"b":1,"2":1,"1":1} | {"1":1,"2":1,"b":1} | 整数に見えるキーが先頭へ並び替わる |
整形したテキストを正本として保存・送信しないほうが安全です。元のテキストは残し、整形結果は見るだけに使います。
同様に、CSV⇔JSON変換ツールも、JSONからCSVへの変換では JSON.parse した値を String() で文字にしています。数値のIDは丸められた値で出力され、文字列で持っているIDは元のまま出力されました。一方、CSVからJSONへの変換は値を文字列のまま保持するため、IDがずれる心配はありません。CSVとJSONの変換全般はCSVをJSONに変換する方法も参考にしてください。
直し方1:送る側でIDを文字列にする(最も確実)
APIや出力を自分で変更できる場合は、IDを文字列にして返すのが最も確実です。
{ "id": "6476060033855273896", "name": "a" }
文字列なら、JSON.parse を通っても値は変わりません。実測では、文字列のIDは JSON.stringify で戻しても元のままでした。IDは計算に使わない値なので、数値である必要はありません。CSVの先頭ゼロ消えと同じく、IDやコードは文字列で扱うのが原則です。
直し方2:受け取る側で対処する(送る側を変えられないとき)
送る側を変えられないときは、解析の前にテキストを加工する必要があります。解析後に BigInt に変換しても手遅れです。
| 方法 | 結果(実測) |
|---|---|
BigInt(JSON.parse(text).id) | 丸められた後の値になる(6476060033855273984) |
JSON.parse の第2引数(reviver)で元の文字列を取る | Node v20.20.1 では3つ目の引数が undefined(元のソース文字列を取れない) |
JSON.rawJSON | Node v20.20.1 には存在しない(undefined) |
| 解析前にテキストを加工して文字列にする | 動作する(下記) |
reviver で元のソース文字列を取れる仕組みは、新しいJavaScriptエンジンでは使える可能性がありますが、この環境では使えませんでした。新しいバージョンや各ブラウザでの対応状況は確認していません。
正規表現だけで囲むと、文字列の中を壊す
「16桁以上の数字を見つけて引用符で囲む」という正規表現は、よく見かける方法です。ただ、単純に書くと文字列の内側にある数字まで囲んでしまいます。
const t = '{"memo":"order 6476060033855273896 shipped","id":6476060033855273896}';
t.replace(/(?<![\w."])(-?\d{16,})(?![\w."])/g, '"$1"');
// {"memo":"order "6476060033855273896" shipped","id":"6476060033855273896"}
// ↑ memo の中の引用符でJSONが壊れる
メモ欄や注文番号など、文字列の値に長い数字が入っているだけで、JSONとして不正になります。 実測でも、この加工後のテキストはそのままでは解析できません。
文字列の外だけを見て加工する
文字列リテラルの中は読み飛ばし、外側の整数トークンだけを調べる書き方なら、この問題を避けられます。次のコードは、Node.js v20.20.1 で下記の入力を試して動作を確認したものです。
function quoteBigInts(text) {
let out = "", i = 0;
while (i < text.length) {
const ch = text[i];
if (ch === '"') {
let j = i + 1;
while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
out += text.slice(i, j + 1);
i = j + 1;
} else if (ch === "-" || (ch >= "0" && ch <= "9")) {
const tok = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i))[0];
const isInt = /^-?\d+$/.test(tok);
out += isInt && !Number.isSafeInteger(Number(tok)) ? `"${tok}"` : tok;
i += tok.length;
} else {
out += ch;
i++;
}
}
return out;
}
const obj = JSON.parse(quoteBigInts(text));
BigInt(obj.id); // 文字列から変換すれば正確
メモ欄の文字列、配列内の数値、負数、小数・指数表記、13桁のタイムスタンプを含む入力で試したところ、危ない整数だけが文字列になり、メモ欄の文字列や安全な範囲の数値、小数・指数表記はそのままでした。
注意点が2つあります。
- このコードは正しいJSONが入力される前提です。壊れたJSONは判定しません
- 安全な範囲を超えた整数を文字列にするため、受け取った後のコードで、その項目が「数値」から「文字列」に変わります。 型を前提にした処理がある場合は修正が必要です
json-bigint のようなライブラリを使う方法もありますが、今回は検証していません。
受け取ったあとで気づく:検出のしかた
解析済みの値は Number.isInteger(v) && !Number.isSafeInteger(v) で調べられます。2の53乗を超えた整数がすべて対象になるため、たまたま正確に表せている値も警告されます(見逃しより過検出を優先)。オブジェクトや配列を再帰的にたどって $.id のようなパスを集める関数を書いて試したところ、{"id":6476060033855273896,"list":[1,9007199254740993],"s":"6476060033855273896"} からは $.id と $.list[1] だけが検出され、文字列のIDと13桁のミリ秒は対象外でした。
ただし、これは丸められた後の値からの判定で、元の値は復元できません。検出したら、送る側にIDを文字列で返すよう依頼するのが根本的な対処です。
タイムスタンプでも同じことが起きる
19桁のナノ秒の値も同様で、1700000000123456789 を Number にして戻すと 1700000000123456800 になりました。ミリ秒(13桁)までは問題ありません。マイクロ秒・ナノ秒は文字列か BigInt で持ち、BigInt("1700000000123456789") / 1000000n のように割れば 1700000000123n と正確に出せます。単位の違いはUnixタイムスタンプとは、変換はUnixタイムスタンプ変換ツールを参照してください。
切り分けチェックリスト
IDがずれていそうなときは、次の順で確認します。
- 元のレスポンスを整形せずにそのまま保存して、IDを目で確認する
- IDは何桁か(17桁以上なら丸めを疑う)
- その値は JSON 上で数値か文字列か(引用符で囲まれているか)
- 受け取り側が
JSON.parseやfetchのres.json()を使っていないか(new Response(...).json()でも丸められることを確認しました) - 整形ツールや変換ツールを経由していないか
JSON自体が壊れている場合の原因は、JSONエラーの直し方と、見えない文字が原因のJSON.parseがBOMで失敗するケースを参照してください。JavaScriptの数値の誤差全般については、金額計算で1円ずれる原因で扱っています。
まとめ
- JavaScriptで正確に扱える整数は 9,007,199,254,740,991 まで。超えると例外なしで近い値に丸められ、別のIDが同じ値になることもある
- 実測では、ランダムな整数のうち丸められた割合は 16桁で5.43%、17桁で83.68%、18桁で98.31%、19桁で99.82%。17桁以上はほぼ確実に壊れる
- 整形ツールで貼り直した時点で値が変わることがある。「検証OK」でも値は保証されない。元のテキストは書き換えずに残す
- 直し方は、送る側でIDを文字列にするのが基本。変えられないときは、解析の前に文字列にする。解析後の
BigInt変換では遅い - 正規表現で数字を囲むだけの対処は、文字列の中の数字まで囲んでJSONを壊す。文字列の外だけを見て加工する
なお、この記事の実測は Node.js v20.20.1 で行ったものです。ブラウザ、Node.js の新しいバージョン、Python・PHP・Java など他の言語、jq などのコマンド、各種データベースでの挙動は確認していません。一般的な整理であり、個別の環境での結果を保証するものではありません。