循環参照と JSON.stringify()

オブジェクトが自分自身を参照している状態を循環参照と呼びます。循環参照があるオブジェクトを JSON.stringify() で変換しようとするとエラーになります。この問題の原因と対処法を解説します。

循環参照とは

オブジェクトのプロパティが、直接または間接的に自分自身を参照している状態です。

const obj = { name: "test" };
obj.self = obj; // 自分自身を参照

// A → B → A のような間接的な循環
const a = { name: "A" };
const b = { name: "B" };
a.ref = b;
b.ref = a;

JSON.stringify() でのエラー

循環参照があるオブジェクトを JSON.stringify() すると、TypeError が発生します。

const obj = { name: "test" };
obj.self = obj;

try {
  JSON.stringify(obj);
} catch (e) {
  console.log(e.message);
  // "Converting circular structure to JSON"
}

JSON は本質的にツリー構造であり、循環を表現できないためです。

循環参照が発生しやすいケース

DOM 要素の参照

DOM ノードは親子関係で相互参照しているため、そのまま JSON 化できません。

親子関係を持つオブジェクト

ツリー構造で parent と children を相互に参照する場合に発生します。

// 親子関係での循環参照
const parent = { name: "親" };
const child = { name: "子", parent: parent };
parent.children = [child];

// child.parent → parent → parent.children → child → ...
JSON.stringify(parent); // エラー

解決策1: replacer で循環を除外

replacer 関数を使って、循環参照を引き起こすプロパティを除外します。

const parent = { name: "親" };
const child = { name: "子", parent: parent };
parent.children = [child];

const json = JSON.stringify(parent, (key, value) => {
  if (key === "parent") {
    return undefined; // 除外
  }
  return value;
});

console.log(json);
// '{"name":"親","children":[{"name":"子"}]}'

解決策2: 循環参照を検出して置換

汎用的に循環参照を検出し、特別な値に置き換える方法です。

function stringifySafe(obj) {
  const seen = new WeakSet();
  
  return JSON.stringify(obj, (key, value) => {
    if (typeof value === "object" && value !== null) {
      if (seen.has(value)) {
        return "[Circular]";
      }
      seen.add(value);
    }
    return value;
  });
}

const obj = { name: "test" };
obj.self = obj;

console.log(stringifySafe(obj));
// '{"name":"test","self":"[Circular]"}'

WeakSet で処理済みオブジェクトを記録

既に処理したオブジェクトに遭遇したら循環と判定

循環部分を “[Circular]” などの文字列に置換

解決策3: structuredClone は循環参照に対応

structuredClone() は循環参照を持つオブジェクトも正しくコピーできます。ただし、JSON 文字列にはなりません。

const obj = { name: "test" };
obj.self = obj;

const copy = structuredClone(obj);
console.log(copy.self === copy); // true(循環構造も維持)

解決策4: toJSON メソッドの実装

クラスに toJSON メソッドを実装すると、JSON.stringify() の挙動をカスタマイズできます。

class Node {
  constructor(name) {
    this.name = name;
    this.parent = null;
    this.children = [];
  }
  
  addChild(child) {
    child.parent = this;
    this.children.push(child);
  }
  
  toJSON() {
    // parent は除外して返す
    return {
      name: this.name,
      children: this.children
    };
  }
}

const root = new Node("root");
const child1 = new Node("child1");
root.addChild(child1);

console.log(JSON.stringify(root, null, 2));
// {"name":"root","children":[{"name":"child1","children":[]}]}

デバッグ時の注意

console.log() は循環参照を扱えますが、JSON.stringify() でフォーマットしようとするとエラーになります。

const obj = { name: "test" };
obj.self = obj;

console.log(obj);                    // OK(ブラウザが適切に表示)
console.log(JSON.stringify(obj, null, 2)); // エラー

循環参照はデータ構造の設計時に意識し、JSON 化が必要な場面では適切に処理することが重要です。