Web便利ツール集

JSONの大きな数値IDが末尾ずれる原因と対処|JavaScriptで16桁超が丸められる理由

JSONJavaScriptBigInt数値精度ID

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の数値戻した結果変わったか
90071992547409919007199254740991変わらない
90071992547409929007199254740992変わらない
90071992547409939007199254740992変わる(1ずれる)
90071992547409959007199254740996変わる
1234567890123456712345678901234568変わる
123456789012345678123456789012345680変わる
64760600338552738966476060033855274000変わる(末尾が 000)
17000000001234567891700000000123456800変わる
17000000001231700000000123変わらない

末尾が 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.501、1.5末尾の0が消える
1e2100指数表記が展開される
1e400null範囲外の数値が 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.rawJSONNode 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がずれていそうなときは、次の順で確認します。

  1. 元のレスポンスを整形せずにそのまま保存して、IDを目で確認する
  2. IDは何桁か(17桁以上なら丸めを疑う)
  3. その値は JSON 上で数値か文字列か(引用符で囲まれているか)
  4. 受け取り側が JSON.parse や fetch の res.json() を使っていないか(new Response(...).json() でも丸められることを確認しました)
  5. 整形ツールや変換ツールを経由していないか

JSON自体が壊れている場合の原因は、JSONエラーの直し方と、見えない文字が原因のJSON.parseがBOMで失敗するケースを参照してください。JavaScriptの数値の誤差全般については、金額計算で1円ずれる原因で扱っています。

まとめ

  1. JavaScriptで正確に扱える整数は 9,007,199,254,740,991 まで。超えると例外なしで近い値に丸められ、別のIDが同じ値になることもある
  2. 実測では、ランダムな整数のうち丸められた割合は 16桁で5.43%、17桁で83.68%、18桁で98.31%、19桁で99.82%。17桁以上はほぼ確実に壊れる
  3. 整形ツールで貼り直した時点で値が変わることがある。「検証OK」でも値は保証されない。元のテキストは書き換えずに残す
  4. 直し方は、送る側でIDを文字列にするのが基本。変えられないときは、解析の前に文字列にする。解析後の BigInt 変換では遅い
  5. 正規表現で数字を囲むだけの対処は、文字列の中の数字まで囲んでJSONを壊す。文字列の外だけを見て加工する

なお、この記事の実測は Node.js v20.20.1 で行ったものです。ブラウザ、Node.js の新しいバージョン、Python・PHP・Java など他の言語、jq などのコマンド、各種データベースでの挙動は確認していません。一般的な整理であり、個別の環境での結果を保証するものではありません。

この記事で使うツール

関連記事