k8o

Blog

数値の精度を失わずにJSONを読み書きするJSON source text access

JavaScriptの数値は倍精度の浮動小数点数なので、JSON.parseで巨大な数値を読むと精度が失われます。Baseline 2025のJSON source text accessを使うと、reviverのcontext.sourceで元のJSONテキストを読み取り、JSON.rawJSONで指定したテキストをそのまま書き出せるので、BigIntと組み合わせて欠損のないJSONの読み書きができます。

11分で読めます
公開: 2026年7月30日(木)
更新: 2026年7月30日(木)
Newly availableJSON source text access2025年〜
  • Chrome114
  • Edge114
  • Firefox135
  • Safari18.4

JSONの数値とJavaScriptの数値のずれ

JSONのテキストには、桁数の制限なく数値を書けます。 一方でJavaScriptの数値はIEEE 754の倍精度の浮動小数点数なので、誤差なく扱える整数はNumber.MAX_SAFE_INTEGER(2^53 - 1 = 9007199254740991)までです。

データベースの64ビット整数のIDやスノーフレークIDのように、この範囲を超える数値がJSONで送られてくると、JSON.parseした時点で精度が失われます。

ts
const json = '{"value": 12345678901234567890}';
console.log(JSON.parse(json).value); // 12345678901234567000

書き出す側も同様です。

ts
console.log(JSON.stringify({ value: 12345678901234567890 }));
// {"value":12345678901234567000}

12345678901234567890に対応するJavaScriptの数値は存在せず、コードに書いた数値リテラルの時点ですでに丸められています。 そのため、これまではJSON.stringifyでこのテキストを出力する方法がありませんでした。

JavaScriptには任意精度の整数を表すBigIntがありますが、これまでのJSONの読み書きとは噛み合いませんでした。

  • 読み取り時は、パース結果を加工するreviver関数が呼ばれる時点で、すでに精度が落ちた数値になっている
  • 書き出し時は、JSON.stringifyBigIntをシリアライズできずTypeErrorを投げる

そのため、サーバー側で数値を文字列として送るようにレスポンスの形式を変えたり、独自のJSONパーサーを持つライブラリを使ったりする回避策が必要でした。

JSON source text accessは、値へ変換される前のJSONテキストと、シリアライズで出力されるJSONテキストそのものへアクセスする手段を提供して、この問題を解決します。

読み取り時に元のテキストを参照するreviverのcontext

JSON.parseは第2引数に、パース結果を加工するためのreviver関数を受け取ります。 パースで生まれた値ひとつひとつについて(key, value)の形で呼ばれ、返した値がその位置の値として採用されます。

ts
const doubled = JSON.parse('{"a": 1, "b": 2}', (key, value) =>
  typeof value === 'number' ? value * 2 : value,
);
console.log(doubled); // { a: 2, b: 4 }

このreviverに、第3引数contextが追加されました。 contextは呼び出しのたびに新しく作られるオブジェクトで、処理中の値についての追加情報を保持します。 いまのところ持つのはsourceプロパティだけで、処理中の値に対応する元のJSONテキストがそのまま入っています。

ts
const json = '{"balance": 12345678901234567890}';
const data = JSON.parse(json, (key, value, context) =>
  key === 'balance' ? BigInt(context.source) : value,
);
console.log(data.balance); // 12345678901234567890n

valueは数値への変換がすでに終わった、精度の落ちた値です。 一方でcontext.sourceには、変換前のテキスト'12345678901234567890'が残っています。 これをBigIntに渡して変換をやり直すことで、レスポンスの形式を変えずに欠損なく読み取れます。

なお、fetchのResponse.json()にはreviverを渡せません。 この手法をレスポンスへ適用するときは、response.text()で文字列として受け取ってからJSON.parseに渡します。

sourceプロパティが存在するのはvalueがプリミティブ値(数値、文字列、真偽値、null)の場合だけです。 オブジェクトや配列は、reviver自身の変換によって元のテキストとの対応が保てなくなるため、sourceを持たない空のオブジェクトが渡されます。

ts
JSON.parse('{"a": 1, "b": [2, 3]}', (key, value, context) => {
  console.log(key, value, context);
  return value;
});
// a 1 { source: '1' }
// 0 2 { source: '2' }
// 1 3 { source: '3' }
// b [ 2, 3 ] {}
//  { a: 1, b: [ 2, 3 ] } {}

配列[2, 3]の各要素にはそれぞれのsourceが渡される一方、配列そのものであるbと、最後に呼ばれるルートのオブジェクト(keyは空文字列)ではcontextが空になっています。

書き出すテキストを直接指定するJSON.rawJSON

書き出し側の対になる機能がJSON.rawJSONです。 渡したテキストをそのままJSONテキストとして埋め込む、raw JSONオブジェクトを作ります。

ts
const raw = JSON.rawJSON('12345678901234567890');
console.log(JSON.stringify({ balance: raw }));
// {"balance":12345678901234567890}

JSON.stringifyに数値をそのまま渡したときは12345678901234567000へ丸められていましたが、今度は桁を保ったまま出力されています。 値がJavaScriptの数値を一度も経由しないので、丸めが起きる場所がありません。

JSON.rawJSONが返すのは、数値そのものではなく専用のオブジェクトです。 上のrawを覗くと、渡したテキストを保持するrawJSONプロパティだけを持っていることがわかります。

ts
console.log(raw); // [Object: null prototype] { rawJSON: '12345678901234567890' }
console.log(raw.rawJSON); // 12345678901234567890

表示に出ているとおりプロトタイプはnullで、さらにこのオブジェクトは凍結されています。 通常のオブジェクトとしてシリアライズされたり、あとから書き換えられたりすることはありません。

JSON.rawJSONに渡せるのは、プリミティブ値を表す有効なJSONテキストだけです。 不正なJSONはもちろん、オブジェクトや配列を表すテキストもSyntaxErrorになるので、出力されるJSONの構造を壊すようなテキストは注入できません。

ts
JSON.rawJSON('12,3'); // SyntaxError
JSON.rawJSON('{"a": 1}'); // SyntaxError
JSON.rawJSON('[1, 2]'); // SyntaxError

数値以外の用途

プリミティブ値を表すテキストであれば、数値以外も指定できます。 たとえばJSON.stringifyは文字列中の絵文字などをそのまま出力しますが、JSON.rawJSONを使うとエスケープ済みの表現を保ったまま出力できます。

ts
const raw = JSON.rawJSON('"\\ud83d\\ude04"');
const json = JSON.stringify({ emoji: raw });
console.log(json); // {"emoji":"\ud83d\ude04"}
console.log(JSON.parse(json).emoji); // 😄

受け取る側の環境がUnicodeをうまく扱えない場合でも、ASCIIの範囲に収めたJSONを生成できます。

replacerでBigIntを書き出す

JSON.stringifyは第2引数に、シリアライズする値を加工するためのreplacer関数を受け取ります。 reviverと同じく値ひとつひとつについて(key, value)の形で呼ばれ、返した値がシリアライズの対象になります。

ts
const doubled = JSON.stringify({ a: 1, b: 2 }, (key, value) =>
  typeof value === 'number' ? value * 2 : value,
);
console.log(doubled); // {"a":2,"b":4}

このreplacerでBigIntをraw JSONオブジェクトに変換すれば、書き出しも欠損なく行えます。

ts
const json = JSON.stringify({ balance: 12345678901234567890n }, (key, value) =>
  typeof value === 'bigint' ? JSON.rawJSON(value.toString()) : value,
);
console.log(json); // {"balance":12345678901234567890}

raw JSONオブジェクトを判定するJSON.isRawJSON

JSON.isRawJSONは、値がJSON.rawJSONで作られたraw JSONオブジェクトかどうかを判定します。 raw JSONオブジェクトは内部的にマークされているため、同じ形をしたオブジェクトを自作してもtrueにはなりません。

ts
console.log(JSON.isRawJSON(JSON.rawJSON('123'))); // true
console.log(JSON.isRawJSON({ rawJSON: '123' })); // false

シリアライズ処理をラップするライブラリやユーティリティで、受け取った値をraw JSONオブジェクトとして特別扱いすべきか判定する用途に使えます。

おわりに

JSONテキストとJavaScriptの値の間で失われていた情報にアクセスできるようにする、JSON source text accessを紹介しました。

読み取りはreviverのcontext.sourceを、書き出しはJSON.rawJSONを使います。 BigIntのような任意精度の表現と組み合わせれば、レスポンスの形式を変えることなく欠損のない読み書きができます。

JSON source text accessはBaseline 2025で各ブラウザに揃いました。 これまでは独自のJSONパーサーを持つライブラリを入れたり、レスポンスの形式そのものを変えたりして避けてきた問題です。 それが追加の依存なしに、標準の機能だけで解けるようになりました。

読了率 0%
もくじ