12k
All articles

JSON によるオブジェクトの平坦化を防ぐ方法

JavaScriptのJSONフラット化をreplacer、reviver、toJSON、context.sourceで防ぎ、Date、Map、Set、BigIntを正しく復元します。

OpenReplay Team
OpenReplay Team
JSON によるオブジェクトの平坦化を防ぐ方法

JSON.stringify は Date を変換する際にその toJSON メソッドを呼び出し、ISO 8601 形式の文字列を返します。一方 JSON.parse には対応する処理が存在しないため、自分で reviver を使って変換しない限り、値は文字列として戻ってきます。

この問題はたいてい同じ形で現れます。キャッシュされたオブジェクトが問題なく localStorage に入り、問題なく取り出され、その後 .getFullYear() が例外を投げたり、テーブルのセルに Invalid Date が表示されたりするのです。データが壊れたわけではありません。2 つの呼び出しの間のどこかで、単に Date ではなくなっただけです。

本記事では往復の両方向を扱います。往路の toJSON と replacer、復路の reviver、そして、あなたが目にする前に精度が失われてしまう値のための reviver の第 3 引数です。基本はすでに理解していることを前提としています。まずそちらを知りたい場合は、how to read and write JSON in JavaScript を参照してください。この記事は第 2 引数から始まります。

要点

  • JSON.stringify は Date を toJSON を通して ISO 文字列としてシリアライズし、JSON.parse は reviver が変換しない限りその文字列をそのまま返します。
  • reviver から undefined を返すとそのキーは結果から消えるため、すべての reviver には処理しないキーのための最後の return value が必要です。
  • reviver はすべてのキーと値のペアに対して、子から親の順に実行され、最後にキー "" のもとでパース結果全体に対してもう一度実行されます。
  • Map と Set は {} にシリアライズされるため、復元するには対になるように書かれた replacer と reviver が必要です。
  • reviver の第 3 引数はコンテキストオブジェクトで、その source プロパティには元の JSON テキストが入っており、Number が丸めてしまう前に大きな整数を BigInt として読み取ることができます。

壊れた JSON ラウンドトリップとはどのようなものか

const session = { user: "ada", lastLogin: new Date("2024-03-01T09:30:00Z") };

const wire = JSON.stringify(session);
// '{"user":"ada","lastLogin":"2024-03-01T09:30:00.000Z"}'

const back = JSON.parse(wire);
typeof back.lastLogin; // "string"
back.lastLogin.getFullYear(); // TypeError

往路の変換は問題ありません。問題なのは、その文字列が元は何だったのかを知る術のない復路の変換です。

JSON シリアライズで生き残るものと失われるもの

JSON の文法には JavaScript オブジェクトが保持する内容の大半に対応する枠がないため、その範囲外のものはすべて変換されるか破棄されます。MDN には JSON.stringify のシリアライズ規則一式が記載されています。下表の 3 列目は、パース後に実際に得られる値です。

値JSON.stringify が書き出すものJSON.parse が返すもの
DatetoJSON による ISO 文字列文字列
Map、Set、WeakMap、WeakSet{}空オブジェクト
オブジェクト内の undefined、関数、シンボルプロパティが省略されるプロパティが存在しない
配列内の同じ値nullnull
NaN、Infinitynullnull
BigIntTypeError を投げる該当なし
クラスインスタンス列挙可能な自身のプロパティからなるプレーンオブジェクトプレーンオブジェクト、プロトタイプは失われる
シンボルをキーとするプロパティ無視される存在しない
循環参照TypeError を投げる該当なし
ボックス化された Number、String、Booleanアンラップされたプリミティブプリミティブ

2 つの行は特に強調しておく価値があります。Map と Set が {} になるのは、JSON.stringify がオブジェクト自身の列挙可能なプロパティを走査するのに対し、それらのエントリはそこに存在しないからです。また、オブジェクト内では undefined、関数、シンボル値は完全に省略されますが、配列内では同じ値が null になるため、値は失われてもインデックスは保たれます。

toJSON が書き出される内容を決める

値が toJSON メソッドを持つ場合、JSON.stringify はそのメソッドが返したものを書き出し、オブジェクト自体は無視します。MDN の toJSON の例 では、そのメソッドに値が置かれているキーが渡されることも示されており、同じオブジェクトでも出現場所によって異なる形で出力できます。

class Money {
  constructor(amount, currency) {
    this.amount = amount;
    this.currency = currency;
  }
  format() {
    return `${(this.amount / 100).toFixed(2)} ${this.currency}`;
  }
  toJSON() {
    return { __type: "Money", amount: this.amount, currency: this.currency };
  }
}

JSON.stringify({ total: new Money(4599, "EUR") });
// '{"total":{"__type":"Money","amount":4599,"currency":"EUR"}}'

__type フィールドは、reviver が探すことになる判別子です。これを書き出すことが、契約の往路側の役割です。

JSON.parse の reviver は復路で実行される

reviver は JSON.parse の第 2 引数で、パースによって生成されるすべてのキーと値のペアに対して呼び出されます。MDN の走査例 がその順序を示しています。最も深い値が先に処理され、次にそれを含むもの、そして最後の 1 回がキー "" のもとで結果全体を対象とします。

JSON.parse('{"a":1,"b":{"c":2,"d":{"e":3}}}', (key, value) => {
  console.log(JSON.stringify(key));
  return value;
});
// "a", "c", "e", "d", "b", ""

そして、静かにデータを破壊するルールがこれです。reviver から undefined を返すとそのキーはオブジェクトから消え、ルートの呼び出しでそれを行うとパース結果全体が undefined になります。

const json = '{"user":"ada","lastLogin":"2024-03-01T09:30:00.000Z"}';

// 破壊的: 処理されないキーはすべて末尾で落ちて削除される。
JSON.parse(json, (key, value) => {
  if (key === "lastLogin") return new Date(value);
});
// undefined

// 正しい書き方: フォールバックの return によって他のすべてが保たれる。
JSON.parse(json, (key, value) =>
  key === "lastLogin" ? new Date(value) : value,
);
// { user: "ada", lastLogin: Date 2024-03-01T09:30:00.000Z }

どちらのバージョンも例外を投げません。それこそが最初のバージョンを危険にしている点です。損失はスタックトレースとしてではなく、フィールドの欠落やレンダリング結果に表示される生の ISO 文字列として現れます。これは、バグレポートで指摘されるよりずっと前にセッションリプレイが浮かび上がらせる類の不具合です。

クラスインスタンスや Map はどう復元するか

本物のインスタンスを復元するには往復の両方が必要です。toJSON がデータと並べて型タグを書き出し、reviver がそのタグを確認して残りのフィールドをコンストラクタに渡します。

const reviver = (key, value) =>
  value && value.__type === "Money"
    ? new Money(value.amount, value.currency)
    : value;

JSON.parse('{"total":{"__type":"Money","amount":4599,"currency":"EUR"}}', reviver)
  .total.format(); // "45.99 EUR"

同じパターンは toJSON を持たない組み込み型にも使えます。Map は replacer によってエントリの配列として出力され、配列の配列を認識する reviver を通して戻ってきます。

const flags = new Map([["beta", true], ["darkMode", false]]);

const text = JSON.stringify({ flags }, (key, value) =>
  value instanceof Map ? Array.from(value.entries()) : value,
);
// '{"flags":[["beta",true],["darkMode",false]]}'

const restored = JSON.parse(text, (key, value) =>
  Array.isArray(value) && value.every(Array.isArray) ? new Map(value) : value,
);
restored.flags.get("beta"); // true

この形状による判定は推測にすぎず、空配列で誤作動します。[].every(Array.isArray) は true なので、ペイロード内のどこにあるプレーンな [] も空の Map として戻ってきてしまいます。Money が書き出しているような型タグを使えば、こうした当て推量はなくなります。

replacer と reviver は、ワイヤーフォーマットに関する 1 つの取り決めです。片方だけを変更すればラウンドトリップは壊れます。

replacer: 往路でのフィルタリング

replacer は JSON.stringify の第 2 引数で、2 つの形式があります。関数として渡した場合はすべてのキーと値のペアに対して実行され、undefined を返すとそのプロパティは省略されます。配列として渡した場合は許可リストとして機能し、文字列と数値のエントリのみが有効で、シンボルを含めそれ以外のものをリストに入れてもまったく効果がありません。

const account = { id: 7, email: "ada@example.com", password: "hunter2" };

JSON.stringify(account, (key, value) => (key === "password" ? undefined : value));
// '{"id":7,"email":"ada@example.com"}'

JSON.stringify(account, ["id", "email"]);
// '{"id":7,"email":"ada@example.com"}'

同じ手法は、そのままでは循環によって JSON.stringify に TypeError を投げさせてしまう既知の逆参照キーを取り除くのにも使えます。汎用的に循環に対応できるシリアライザには、訪問済みオブジェクトを保持する WeakSet が必要です。名前のわかっている 1 つのキーを取り除くだけでは、把握しているケースにしか対応できません。

1 つ、タイミングに関する重要な点があります。toJSON は replacer が値を見る前に実行されるため、Date の場合、replacer の value 引数はすでに ISO 文字列である一方、this[key] はまだ元のオブジェクトです。

JSON.stringify({ lastLogin: new Date() }, function (key, value) {
  // 通常の関数である必要がある: アロー関数はここで `this` バインディングを持たない。
  return key === "lastLogin" ? this[key].getTime() : value;
});

第 3 引数の space は整形にのみ影響します。10 を超えるスペース数を指定しても得られるのは 10 までで、10 文字を超えるインデント文字列は先頭 10 文字に切り詰められます。

context.source で元のテキストを読む

reviver の第 3 引数は、呼び出しごとに新しく作られるコンテキストオブジェクトで、その source プロパティにはその値に対応する元の JSON テキストが入っています。この引数はプリミティブに対してのみ渡され、オブジェクトや配列では何も得られません。これは TC39 の JSON.parse source text access プロポーザルで、Stage 4 に到達し、2026 年 6 月 30 日に Ecma International によって承認された ECMAScript 2026 に搭載されました。

これは、どんな reviver も介入できないタイミングで起きる損失を解決します。あなたが value を受け取る時点では、大きな整数はすでに倍精度浮動小数点数へ丸められているのです。

const wire = '{"orderId": 9007199254740993}';

JSON.parse(wire).orderId;
// 9007199254740992  <- 精度はすでに失われている

JSON.parse(wire, (key, value, context) =>
  key === "orderId" ? BigInt(context.source) : value,
).orderId;
// 9007199254740993n

value は精度が失われた結果です。context.source は実際にワイヤー上にあったものです。依存する前に、対象ランタイムでの利用可否を確認してください。

まとめ

シリアライズは 2 度書く契約です。1 度目は toJSON または replacer で、2 度目はその前半が生成したものを理解する reviver で書きます。localStorage やキャッシュ層に送り込んでいるオブジェクトを一通り確認し、Date、Map、Set、クラスインスタンスを持つものを見つけて、それぞれに型タグと対応する reviver の分岐を与えましょう。そして、すでに存在するすべての reviver が、フォールバックの return value で終わっているかを確認してください。

FAQ

structuredClone を使えば reviver は不要になりますか?

いいえ。structuredClone は JSON 文字列ではなくメモリ内のコピーを生成するため、localStorage やリクエストボディに書き込むことはできません。確かに Date、Map、Set、循環参照は保持しますが、関数に対しては DataCloneError を投げ、プロトタイプチェーンはコピーしないため、クラスインスタンスは依然としてメソッドを持たないプレーンオブジェクトとして届きます。テキストからインスタンスを復元するには、やはり reviver が必要です。

toJSON と replacer 関数のどちらを使うべきですか?

型自体がワイヤーフォーマットを所有する場合は toJSON を使います。メソッドはクラス上に存在するため、呼び出し側が何もしなくても、その値のあらゆるシリアライズが同じ形を出力します。パスワードフィールドを取り除く、制御下にないライブラリの Map を変換するなど、ルールが 1 つの呼び出し箇所に属する場合は replacer を使います。toJSON が先に実行されるため、replacer は toJSON が返したものを受け取ります。

reviver は配列要素に対しても実行されますか?

はい。配列のインデックスは文字列として reviver に渡されるため、最初の要素はキー '0' で届き、その後に配列自身がそれ自身のキーのもとで渡されます。要素に対して undefined を返すと、残りがシフトされるのではなくその要素が削除され、配列の length は変わらないまま穴が残ります。配列を扱う reviver にも、オブジェクトの reviver と同じフォールバックの return value が必要です。

TypeScript で JSON.parse の reviver に型を付けるにはどうすればよいですか?

TypeScript では、reviver が何をしようと JSON.parse は any を返します。標準ライブラリは reviver を、string のキー、any の値、any の戻り値型で宣言しているため、Date やクラスインスタンスを再構築する reviver はコンパイラに追加情報を与えません。呼び出し箇所で明示的な型を結果に注釈するか、パースした値をスキーマバリデータに通してから形状を信頼するようにしてください。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.