All questions

Superjson II

Premium

Superjson II

You're snapshotting application state — a graph of nodes where the same object is reachable from several places, and nodes point back at their parents. Think of a comment tree where every reply holds a reference to its thread, or an undo history where two steps share the exact same record. The moment you reach for JSON.stringify, two things go wrong: a value reachable from two places is written out twice as separate copies, and a value that points back at itself (a cycle) makes stringify throw Converting circular structure to JSON.

This is the sequel to the base superjson, which round-trips special types like Date, Map, and BigInt. Here the focus is different: preserve referential identity. Implement serialize and deserialize so that shared references come back shared, and cyclic structures round-trip instead of throwing.

Signature

// serialize: turn a value (with shared and/or circular references) into one JSON string.
function serialize(value: unknown): string;

// deserialize: rebuild the value, with identity and cycles intact.
function deserialize(str: string): unknown;

// The contract that must hold:
//   - If in.a === in.b, then out.a === out.b  (shared references stay shared)
//   - A cycle (o.self === o, or a → b → a) round-trips instead of throwing
//   - JSON.parse(serialize(value)) never throws  (the output is valid JSON)

Examples

// A shared reference comes back as ONE instance.
const shared = { id: 7 };
const out = deserialize(serialize({ a: shared, b: shared }));
out.a === out.b;        // true — not two separate copies
out.a;                  // { id: 7 }

// Plain JSON loses this — a and b deserialize to distinct objects:
const naive = JSON.parse(JSON.stringify({ a: shared, b: shared }));
naive.a === naive.b;    // false
// A self-referencing object round-trips into the same cycle.
const o = {};
o.self = o;
const back = deserialize(serialize(o));
back.self === back;     // true — the cycle is rebuilt

// Plain JSON can't even produce the string:
JSON.stringify(o);      // throws: Converting circular structure to JSON

Notes

  • Identity is preserved. Whenever two slots in the input point at the same object or array, the two matching slots in the output must point at one reconstructed object. The reverse also holds: two distinct objects that merely look equal must stay distinct — don't collapse them.
  • Cycles are supported. Direct (o.self = o) and transitive (a.b = b; b.a = a, or longer) cycles must serialize without infinite recursion and deserialize back into the same loop.
  • What JSON.stringify does instead. It inlines every reference, so a shared object becomes two copies (identity lost), and it throws outright on the first cycle it hits. Both are the bugs this question fixes.
  • Scope is objects and arrays. Only objects and arrays carry identity worth preserving. Primitives — strings, numbers, booleans, null — are values, not references; they round-trip as-is and never get an id.
  • The output must be valid JSON. JSON.parse(serialize(value)) must not throw. How you encode the reference information inside that string is up to you, but it must survive a plain JSON.parse.
  • Don't worry about the special types from the base question (Date, Map, Set, BigInt, undefined), functions, symbols, or class prototypes. They're out of scope here; combining identity-tracking with those types appears under Going further in the solution.

Unlock the solution & editor

  • Runnable editor + tests

    Solve in the browser with instant Jest feedback.

  • Detailed solutions

    Walkthroughs, edge cases, and complexity notes.

  • Multi-framework variants

    React, Vue, Vanilla, Angular — same question, different stacks.

Upgrade to Premium