1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JSON を通すと数値が変わる? 大きなID・NaN・-0・BigInt・1.50 で起きる5つのずれ

1
Posted at

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)の設計までセットで含みます。

※ 中上級者には易しすぎる内容なので、初心者の知り合いへの紹介や社内研修の参考としてどうぞ。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?