API から受け取った ID が1だけずれている。保存したはずの平均値が null になっている。どちらも、JSON を通しただけで数値が変わる典型的な場面です。
この記事では、JSON を経由すると数値が別の値になる(1つはエラーで止まる)5つのケースを、Node.js v24.14.1 で実行した結果つきで整理します。JSON.stringify / JSON.parse の型まわりの落とし穴(Date が Date に戻らない・undefined が消える・循環参照)は 以前の記事 で扱ったので、今回は「数値」だけに絞ります。
5つのずれ一覧
| # | 元の値 | JSON を通した後 | 起きるタイミング |
|---|---|---|---|
| 1 | 9007199254740993 |
9007199254740992 |
JSON.parse |
| 2 |
NaN / Infinity
|
null |
JSON.stringify |
| 3 | -0 |
0 |
JSON.stringify |
| 4 |
10n(BigInt) |
TypeError |
JSON.stringify |
| 5 | 1.50 |
1.5 |
JSON.parse |
1. 2^53 を超える整数は、parse した時点で別の数になることがある
const text = '{"id": 9007199254740993}';
const obj = JSON.parse(text);
console.log(obj.id); // 9007199254740992
console.log(Number.isSafeInteger(obj.id)); // false
console.log(JSON.parse('{"id": 1234567890123456789}').id);
// 1234567890123456800
JavaScript の number は IEEE 754 の倍精度浮動小数点数なので、すべての整数を正確に表せるのは Number.MAX_SAFE_INTEGER(9007199254740991)までです。それを超えると表せない整数が出てきて、そういう値は表せるいちばん近い値に丸められます(9007199254740993 → 9007199254740992)。エラーも警告も出ません。
なお、超えた整数がすべて丸められるわけではありません。たとえば 2^53〜2^54 の範囲では偶数だけが表せるので、JSON.parse('9007199254740994') はそのまま 9007199254740994 になります。「たまたま合う値もある」せいで、テストでは気づきにくい点も厄介です。
64bit 整数の ID を返す API で起きやすく、そのためか ID を文字列でも返してくれる API もあります。
対処
- 受け取り方を選べるなら、ID は文字列で受け取る(
"id": "9007199254740993") - 数値で届いてしまう場合は、
JSON.parseの reviver(第2引数の関数)の第3引数context.sourceで、丸められる前の元の文字列を取り出す
const obj = JSON.parse(text, (key, value, context) =>
key === "id" && typeof value === "number" ? BigInt(context.source) : value
);
console.log(obj.id); // 9007199254740993n
typeof value === "number" の条件を外すと、{"id": null} が来たときに BigInt の変換で例外になります(Node.js では SyntaxError: Cannot convert null to a BigInt)。入れ子の中に文字列の "id" があるときも同じなので、数値のときだけ変換します。
fetch で受け取る場合、res.json() には reviver を渡せません。await res.text() で文字列のまま受け取ってから、JSON.parse(text, reviver) に渡します。
context.source は、TC39(JavaScript の仕様を決める委員会)の提案「JSON.parse source text access」で追加された機能です。数値や文字列などのプリミティブのときだけ source が入り、配列やオブジェクトのときは入りません(Node.js v24.14.1 で確認)。
記事中のエラーメッセージは Node.js(V8)での文言です。ブラウザによって文言は異なります。
2. NaN と Infinity は null になる(しかも元に戻らない)
JSON.stringify({ avg: 0 / 0, rate: 1 / 0, neg: -1 / 0 });
// '{"avg":null,"rate":null,"neg":null}'
JSON.parse('{"a": NaN}');
// SyntaxError: Unexpected token 'N', "{"a": NaN}" is not valid JSON
2つ目の例のように、NaN と書かれた JSON は parse 自体が失敗します。たとえば Python の json.dumps は初期設定のままだと NaN をそのまま書き出すので(json.dumps({"avg": float("nan")}) → {"avg": NaN})、Python 側で作った JSON を JavaScript で読むとここで止まります。
JSON の仕様には NaN や Infinity の書き方がないため、JSON.stringify は null に置き換えます。parse し直しても null のままです。0件のときの平均値(0 / 0)をそのまま保存すると、「計算できなかった」と「値が入っていない」の区別が消えます。
対処: 送る前に Number.isFinite() で確かめて、意味の決まった値に置き換えます。たとえば「件数が0なら平均は null にする」と決めたうえで、件数も一緒に送れば、受け取る側が区別できます。
3. -0 は 0 になる
JSON.stringify(-0); // '0'
Object.is(JSON.parse("-0"), -0); // true ← parse は -0 を読める
Object.is(JSON.parse(JSON.stringify(-0)), -0); // false ← 往復すると 0
JSON.stringify は -0 を "0" と書き出します(String(-0) が "0" になるのと同じ)。Math.round(-0.4) が -0 になるように、計算の結果として -0 は普通に出てきます。
ほとんどの場面では -0 === 0 が true なので困りません。差が出るのは、符号まで見ている処理です。たとえば Intl.NumberFormat は -0 を "-0" と表示します。
const fmt = new Intl.NumberFormat("ja-JP");
fmt.format(-0); // '-0'
fmt.format(JSON.parse(JSON.stringify(-0))); // '0'
画面で計算した変化量は「-0」と出ていたのに、サーバーに保存して読み直すと「0」になる、という見え方の違いになります。Object.is で比べているテストが、往復のあとで落ちることもあります。
対処: 符号に意味がないなら、表示側で signDisplay: "exceptZero" を指定して -0 も "0" と出すようにそろえます。符号そのものに意味がある値なら、符号を別のフィールドで持ちます。
4. BigInt は stringify できない
JSON.stringify({ n: 10n });
// TypeError: Do not know how to serialize a BigInt
1 の対処で BigInt にした値を、そのまま送り返そうとするとここで止まります。
対処: replacer(第2引数の関数)で文字列にするか、JSON.rawJSON で数値のまま書き出します。
// 文字列として送る
JSON.stringify({ n: 9007199254740993n }, (key, value) =>
typeof value === "bigint" ? value.toString() : value
);
// '{"n":"9007199254740993"}'
// 数値のまま送る
JSON.stringify({ n: 9007199254740993n }, (key, value) =>
typeof value === "bigint" ? JSON.rawJSON(value.toString()) : value
);
// '{"n":9007199254740993}'
JSON.rawJSON は 1 の context.source と同じ提案で追加された機能で、MDN では Baseline 2025 の扱いです。
ただし「数値のまま送る」場合、受け取る側が普通の JSON.parse だと、1 とまったく同じ丸めがもう一度起きます({"n":9007199254740993} を読むと 9007199254740992 になる)。受け取る側も context.source などで読む前提でなければ、文字列で送るほうが安全です。
BigInt.prototype.toJSON を足して解決する方法も見かけますが、実行環境全体(使っているライブラリの JSON.stringify も含む)の挙動が変わるので、replacer で必要な場所だけ変換するほうが影響範囲を小さくできます。
5. 1.50 は 1.5 になる(桁の見た目は残らない)
JSON.parse('{"p2": 1.50, "p3": 1.0, "p4": 1e3}');
// { p2: 1.5, p3: 1, p4: 1000 }
JSON.stringify(JSON.parse('{"x": 100000000000000000000000}'));
// '{"x":1e+23}'
(1e23 は 2^53 を大きく超えているので、2つ目の例では表記が変わるだけでなく 1 の丸めも同時に起きています。中身は BigInt(1e23) で見ると 99999999999999991611392 です)
数値としては同じなので計算は合いますが、「小数第2位まで書いてあった」という見た目の情報は消えます。価格を受け取ってそのまま表示すると、1.50 が 1.5 と出ます。
対処: 表示の桁数は表示する側で決めます(toFixed(2) や Intl.NumberFormat)。バージョン番号の "1.10" のように、桁の並びそのものに意味がある値は、数値ではなく文字列で持ちます(数値で持つと 1.1 になり、"1.1" と区別できません)。
AI が書いた JSON 処理を見るときの確認ポイント
AI に API 連携のコードを書いてもらうと、受け取った値をそのまま number として扱う形で出てくることがあります。動作確認で使うテストデータの ID が小さいと、1 のずれは手元では再現しません。レビューでは次の4点を見ると、本番データで初めて気づく事故を減らせます。
- 外部 API の ID を
numberとして受けていないか(Number.isSafeInteger()で確認できる) - 割り算の結果を、
Number.isFinite()で確かめずにJSON.stringifyしていないか - BigInt を使い始めたら、送る側の
JSON.stringifyも見直したか - 桁の並びに意味がある値(価格の表示・バージョン番号など)を数値型で受けていないか
まとめ
-
JSON.parseで起きるもの: 2^53 を超える整数が丸められることがある、1.50→1.5 -
JSON.stringifyで起きるもの:NaN/Infinity→null、-0→0、BigInt のTypeError - 大きな ID は文字列で受けるのが基本。数値で届くなら reviver の
context.source、BigInt を書き出すなら replacer かJSON.rawJSON
どれもエラーにならずに値だけ変わるものが多いので、「JSON を通ったら数値は変わりうる」と覚えておくだけで、原因にたどり着きやすくなります。
未経験者向けの講座を運営しています
未経験から Next.js + Supabase + Claude Code で Webアプリを公開するまで を、全24セッションで体系化した教材です。Claude Code を学習パートナーにする CLAUDE.md と学習モード(learner / developer)の設計までセットで含みます。
- ▶ ライブデモ(インストール不要・無料で今すぐ見られます)→ https://trial-web-ayies128s-projects.vercel.app/?ref=qiita
- 教材完全版+月5,500円のメンタリング(全24セッション+チャット質問し放題)→ https://menta.work/plan/20251?ref=qiita
- 無料体験版(git clone して自分の手元で動かす・最初の数セッション分・⭐ Star もよろしくお願いします)→ https://github.com/ayies128/next-ai-camp-trial
- YouTube『AIエンジニア情報局』(AI×開発ニュースを1本5分でキャッチアップできる別運営チャンネル・無料)→ https://www.youtube.com/channel/UC1rXVD9WYsQPQEWZyd-A1KA/?ref=qiita
※ 中上級者には易しすぎる内容なので、初心者の知り合いへの紹介や社内研修の参考としてどうぞ。