JSON整形で値が変わる 大きな整数・1.0・1e400とrawJSON

JSON を見やすくするために整形しただけなのに、ID の末尾が 1 つずれていた、1.0 が 1 になった、という報告は珍しくない。ブラウザや Node.js で JSON を整形する処理の多くは、JSON.parse で値に戻してから JSON.stringify で書き直している。この往復で、文字としての JSON は保たれない。

この記事では、往復で変わる値を Node.js で実際に起こし、どこまで原文どおりに残せるかを確かめる。

往復で何が変わるか

次の 1 行を JSON.parse して JSON.stringify で書き戻した。実行環境は Windows 11 上の Node.js v24.11.1。

1
2
3
const src = '{"id":9007199254740993,"price":1.0,"ratio":1.50,"big":1e2,"neg":-0,' +
'"tiny":1e-7,"huge":1e400,"name":"\\u5c71\\u7530","dup":1,"dup":2}';
console.log(JSON.stringify(JSON.parse(src)));
1
{"id":9007199254740992,"price":1,"ratio":1.5,"big":100,"neg":0,"tiny":1e-7,"huge":null,"name":"山田","dup":2}

変わった点を並べる。

原文 往復後 理由
9007199254740993 9007199254740992 Number は 2^53 - 1 を超える整数を正確に持てない
1.0 / 1.50 1 / 1.5 数値に戻した時点で書き方の情報が消える
1e2 100 同上。1e-7 は String() の表記がそのまま 1e-7 なので変わらない
-0 0 JSON.stringify(-0) は "0" を返す
1e400 null 値が Infinity になり、JSON.stringify は有限でない数を null にする
"山田" "山田" エスケープを解いた文字列に戻るため。意味は同じ
"dup":1,"dup":2 "dup":2 後に出たキーで上書きされ、1 つ目は消える

意味が変わらないもの(1.0 と 1、エスケープの有無)もあるが、id の丸めと 1e400 の null 化は値そのものが変わる。しかも例外もエラーも出ない。

仕様側はどう言っているか

JSON の仕様 RFC 8259 は、数値の範囲と精度の上限を実装に任せている。第 6 節は、IEEE 754 binary64(倍精度)が広く使われていること、-(2^53)+1 から (2^53)-1 までの整数なら実装間で値が一致することを書き、1E400 のような値は受け手がそこまでの範囲を持たない可能性がある例として挙げている。

重複キーについては第 4 節が、名前は一意であるべき(SHOULD)とし、一意でないときの受け手の動作は予測できないとしている。JavaScript の JSON.parse は後勝ちだが、別の言語やライブラリでは先勝ちやエラーになり得る。

つまり、上の表の変化は JSON.parse の不具合ではなく、仕様が許す範囲で起きている。困るのは「整形しただけ」のつもりで、値が変わったことに気づかない点だ。

実際に試す: 数値を原文どおりに残す

TC39 の提案 JSON.parse source text access(Stage 4)で、JSON.parse の reviver に第 3 引数 context が渡るようになった。プリミティブ値については context.source に原文の綴りが入る。もう 1 つの JSON.rawJSON(text) は、JSON.stringify に「この文字列をそのまま出力せよ」と伝える凍結オブジェクトを作る。Node.js v24.11.1 ではどちらも追加のフラグなしで使えた。

この 2 つを組み合わせ、数値の綴りが String(value) と食い違うときだけ原文を残す。次を json-lossless.mjs として保存する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// json-lossless.mjs
export function parseLossless(text) {
return JSON.parse(text, function (key, value, context) {
// 数値で、元の綴りと String(value) が違うときだけ原文を残す
if (typeof value === 'number' && context && context.source !== String(value)) {
return JSON.rawJSON(context.source);
}
return value;
});
}

const src =
'{"id":9007199254740993,"price":1.0,"big":1e2,"neg":-0,' +
'"huge":1e400,"tiny":1e-7,"dup":1,"dup":2}';

console.log('plain :', JSON.stringify(JSON.parse(src)));
const obj = parseLossless(src);
console.log('lossless:', JSON.stringify(obj));
console.log(JSON.stringify(obj, null, 2));
console.log('typeof id:', typeof obj.id, JSON.isRawJSON(obj.id));
console.log('tiny :', typeof obj.tiny, obj.tiny);
1
node json-lossless.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
plain   : {"id":9007199254740992,"price":1,"big":100,"neg":0,"huge":null,"tiny":1e-7,"dup":2}
lossless: {"id":9007199254740993,"price":1.0,"big":1e2,"neg":-0,"huge":1e400,"tiny":1e-7,"dup":2}
{
"id": 9007199254740993,
"price": 1.0,
"big": 1e2,
"neg": -0,
"huge": 1e400,
"tiny": 1e-7,
"dup": 2
}
typeof id: object true
tiny : number 1e-7

id・1.0・1e2・-0・1e400 が原文どおりに出力され、インデントを付けた整形にもそのまま使える。綴りが一致する 1e-7 は普通の数値のまま残る。

残した値は数値ではない

JSON.rawJSON が返すのはプロトタイプを持たない凍結オブジェクトで、typeof は object になる。計算に使おうとすると例外になる。

1
2
3
const r = JSON.rawJSON('9007199254740993');
try { r + 1; } catch (e) { console.log(e.name, e.message); }
console.log(r.rawJSON, BigInt(r.rawJSON) + 1n);
1
2
TypeError Cannot convert object to primitive value
9007199254740993 9007199254740994n

整数として計算したいなら、上のように rawJSON プロパティの文字列を BigInt に渡す。この方法は整形や中継のように、値を読まずにそのまま書き戻す用途に向いている。

重複キーは reviver では拾えない

reviver が呼ばれるのは、後勝ちで残ったキーだけだった。上の例でも dup=1 は reviver に一度も現れない。重複を警告したいなら、パースの前に原文を走査するしかない。次は文字列とかっこだけを追う簡易版で、キー名はエスケープを解いてから比べる。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// dupkeys.mjs : 同じ階層に同じキーが2回以上あるかを原文から調べる
export function findDuplicateKeys(text) {
const found = [];
const stack = [];
const re = /"(?:[^"\\]|\\.)*"|[{}\[\]:,]/g;
let m, prev = null;
while ((m = re.exec(text))) {
const t = m[0];
if (t === '{') stack.push(new Set());
else if (t === '[') stack.push(null);
else if (t === '}' || t === ']') stack.pop();
else if (t === ':' && prev && prev[0] === '"') {
const keys = stack[stack.length - 1];
const name = JSON.parse(prev);
if (keys) { if (keys.has(name)) found.push(name); keys.add(name); }
}
prev = t;
}
return found;
}

console.log(findDuplicateKeys('{"dup":1,"a":{"dup":3},"dup":2}'));
console.log(findDuplicateKeys('{"\\u0061":1,"a":2}'));
console.log(findDuplicateKeys('[{"k":1},{"k":2}]'));
1
2
3
[ 'dup' ]
[ 'a' ]
[]

入れ子の別オブジェクトにある同名キーや、配列の要素ごとの同名キーは重複として数えない。"a" と "a" は同じキーとして検出される。構文が正しいかどうかは見ていないので、JSON.parse が通った入力に対して使う前提である。

ハシトシステムの整形ツールでは

ブラウザで使える JSON 整形・検証ツール(ハシトシステム) も、2026-10-26 時点のソースでは JSON.parse の結果を JSON.stringify(obj, null, indent) で書き出している。そのため、上の表と同じ変化が整形結果にも出る。構文チェックや見た目の確認には十分だが、2^53 を超える ID や 1.0 の表記を保ったまま別のシステムへ渡す用途では、整形後の値を原文と見比べるか、本記事の parseLossless のような処理を通すこと。

Snowflake ID のように、もともと大きな整数を数値のまま JSON に入れてしまう例は、Snowflake ID を BigInt で分解する記事 で扱った。文字列側の往復(エスケープ)については JSON 文字列エスケープの落とし穴 を参照してほしい。

まとめ

  • JSON.parse → JSON.stringify の整形は、2^53 超の整数、1.0、1e2、-0、1e400、重複キーを黙って書き換える。
  • reviver の context.source と JSON.rawJSON で、数値の綴りを原文どおりに残せる(Node.js v24.11.1 で確認)。残した値は数値ではなく、計算するなら BigInt(r.rawJSON) を使う。
  • 重複キーは reviver からは見えない。原文を走査して検出する。

参考

  • RFC 8259 The JavaScript Object Notation (JSON) Data Interchange Format(第 4 節 Objects、第 6 節 Numbers)
  • TC39 proposal-json-parse-with-source(JSON.parse source text access)
  • ECMAScript 仕様 JSON.stringify(有限でない数値は null として出力)