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.
// 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)
// 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
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.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.null — are values, not references; they round-trip as-is and never get an id.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.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.