JSON.stringify and JSON.parse are the default way to send JavaScript values over the wire or stash them in localStorage. But JSON only knows about strings, numbers, booleans, null, plain objects, and arrays. Hand it a Date, a Map, a Set, or a BigInt and it quietly mangles them — a Date comes back as a string, a Map comes back as {}, and a BigInt makes JSON.stringify throw outright.
Implement serialize and deserialize so the round-trip preserves both structure and type. After deserialize(serialize(value)), a Date is still a Date (same instant), a Map is still a Map (same entries, same insertion order), a BigInt is still a bigint, and undefined / NaN / Infinity survive instead of being dropped or flattened to null.
// serialize: turn any supported value into a single JSON string.
function serialize(value: unknown): string;
// deserialize: turn that string back into the original value, types intact.
function deserialize(str: string): unknown;
// The contract that must hold for every supported value:
// deserialize(serialize(value)) deep-equals value
// ...and revived special values keep their type (instanceof Date, typeof bigint, ...).
The returned string must be valid JSON — JSON.parse(serialize(value)) must not throw. How you pack the type information into that string is up to you.
// A Date keeps its type and its exact instant.
const str = serialize(new Date('2026-06-04T12:30:00.000Z'));
const back = deserialize(str);
back instanceof Date; // true
back.getTime(); // 1780576200000 (same instant)
// Plain JSON would have lost this:
JSON.parse(JSON.stringify(new Date())) instanceof Date; // false — it's a string
// A nested mixture: a Map (with an object value) inside an object inside an array.
const value = [
{ label: 'config', data: new Map([['retries', 3]]), when: new Date(0) },
];
const back = deserialize(serialize(value));
back[0].data instanceof Map; // true
back[0].data.get('retries'); // 3
back[0].when instanceof Date; // true
// And the values JSON silently eats:
deserialize(serialize({ a: undefined, b: NaN, c: Infinity, d: 9n }));
// → { a: undefined, b: NaN, c: Infinity, d: 9n } (all four preserved)
Date, Map, Set, BigInt, undefined, NaN, Infinity, -Infinity, and any nesting of these inside plain objects and arrays. Primitives (string, finite number, boolean, null) pass through unchanged.Map or Set must iterate its entries in the same order they were inserted.undefined must survive everywhere — as the whole value, as an object property (the key stays present), and as an array element (the slot stays at its index).Map) — those are out of scope here, though a few appear under Going further in the solution.You'll write serialize and deserialize that move a rich JavaScript value through a JSON string and back without losing the value's types.
You have state in memory — a Date, a Map, a BigInt — and you need to get it somewhere JSON lives: a fetch body, a localStorage entry, a React Server Component payload streamed to the browser. The moment you call JSON.stringify, JSON quietly flattens anything it doesn't recognise. The Date becomes a string. The Map becomes {}. The BigInt makes stringify throw. On the other side you JSON.parse and get back a shape that looks right but has lost every non-JSON type. superjson is the wrapper that fixes this: it remembers what each special value was, ships that alongside the data, and rebuilds the real types on arrival.
The core idea is to split the output into two channels that travel together inside one string. The first channel, json, is a JSON-safe clone of your value — every special type swapped for a stand-in JSON already understands (a Date becomes its ISO string, a Map becomes an array of entries, a BigInt becomes a string of digits). The second channel, meta, is a small map from a path — the location of a node inside the tree — to a type tag like "Date" or "bigint". Serializing means walking the value to build both channels; deserializing means parsing them back and using meta to revive each tagged path into its real type.
The reason the type tag lives in a separate channel — keyed by path — rather than inline next to the data is subtle but important. If you instead tagged values inline, say by wrapping every Date as { "$type": "Date", "value": "..." }, then any plain object in the user's own data that happened to have a $type key would be misread as a tag on the way back. Keeping the metadata out of the data entirely means a user string can never be confused for a marker. That is the single design decision the whole approach turns on.
The obvious version is to do nothing special at all — just hand the value to JSON.stringify and trust JSON.parse to give it back:
function serialize(value) {
return JSON.stringify(value);
}
function deserialize(str) {
return JSON.parse(str);
}
This is what most code ships before someone files the bug. It works perfectly for plain objects, arrays, strings, numbers, and booleans — and fails for everything this question is about. serialize(new Date(0)) returns '"1970-01-01T00:00:00.000Z"', and deserialize of that is a plain string, not a Date. serialize(new Map([['k', 1]])) returns '{}' — JSON.stringify doesn't know how to read a Map's entries, so it serializes its (empty) enumerable own properties. serialize(10n) doesn't even return; JSON.stringify throws TypeError: Do not know how to serialize a BigInt. And { a: undefined } round-trips to {} — the key vanishes. The naive version isn't wrong in spirit; it's just blind to every type JSON doesn't natively model.
A tempting second attempt is to special-case types inline with a replacer function and a sentinel wrapper:
function serialize(value) {
return JSON.stringify(value, (key, val) => {
if (val instanceof Date) return { __date: val.toISOString() };
if (typeof val === 'bigint') return { __bigint: val.toString() };
return val;
});
}
This is closer, and a lot of homegrown solutions stop here. But it has two real flaws. First, the replacer receives values after Date has already been converted — a Date's toJSON runs before your replacer sees it, so val instanceof Date is already false and the __date branch never fires. Second, and more fundamentally, the sentinel { __date: ... } lives inside the data: if a user's own object has a __date key, deserializing will revive it as a Date and corrupt their data. The fix for both is to walk the value ourselves (so we see real Dates before any toJSON) and to keep the type tags in a separate channel (so they can never collide with user keys).
// Special values JSON can't represent natively get replaced by a JSON-safe
// stand-in, and their type is recorded in a separate `meta` map keyed by the
// value's PATH inside the tree. We serialize { json, meta } together as one
// string; on the way back we parse it, then walk `meta` to revive each path.
function isPlainObject(v) {
return v !== null && typeof v === 'object' && !Array.isArray(v)
&& !(v instanceof Date) && !(v instanceof Map) && !(v instanceof Set);
}
function serialize(value) {
const meta = {}; // path string -> type tag, e.g. "a.b" -> "Date"
// Walk the value, returning a JSON-safe clone and recording special types in
// `meta`. `path` is the array of keys from the root to the current node.
function walk(node, path) {
const key = path.join('.');
if (node === undefined) {
meta[key] = 'undefined';
return null; // JSON drops undefined; stash null and remember the truth
}
if (typeof node === 'bigint') {
meta[key] = 'bigint';
return node.toString(); // JSON.stringify throws on bigint; store digits
}
if (typeof node === 'number' && !Number.isFinite(node)) {
// NaN, Infinity, -Infinity all serialize to null in plain JSON.
meta[key] = Number.isNaN(node) ? 'NaN' : (node > 0 ? 'Infinity' : '-Infinity');
return null;
}
if (node instanceof Date) {
meta[key] = 'Date';
return node.toISOString();
}
if (node instanceof Map) {
meta[key] = 'Map';
// Recurse into each value, indexing entries positionally so the path is
// stable regardless of what the keys are.
return [...node.entries()].map(([k, v], i) => [
walk(k, [...path, `${i}k`]),
walk(v, [...path, `${i}v`]),
]);
}
if (node instanceof Set) {
meta[key] = 'Set';
return [...node].map((v, i) => walk(v, [...path, String(i)]));
}
if (Array.isArray(node)) {
return node.map((v, i) => walk(v, [...path, String(i)]));
}
if (isPlainObject(node)) {
const out = {};
for (const k of Object.keys(node)) out[k] = walk(node[k], [...path, k]);
return out;
}
return node; // string, finite number, boolean, null — JSON-safe already
}
const json = walk(value, []);
return JSON.stringify({ json, meta });
}
function deserialize(str) {
const { json, meta } = JSON.parse(str);
// Revive a node given its path. We consult `meta` for this exact path first,
// because the type tag tells us what the JSON stand-in really represents.
function revive(node, path) {
const key = path.join('.');
const tag = meta[key];
if (tag === 'undefined') return undefined;
if (tag === 'bigint') return BigInt(node);
if (tag === 'NaN') return NaN;
if (tag === 'Infinity') return Infinity;
if (tag === '-Infinity') return -Infinity;
if (tag === 'Date') return new Date(node);
if (tag === 'Map') {
const m = new Map();
node.forEach(([k, v], i) => {
m.set(revive(k, [...path, `${i}k`]), revive(v, [...path, `${i}v`]));
});
return m;
}
if (tag === 'Set') {
const s = new Set();
node.forEach((v, i) => s.add(revive(v, [...path, String(i)])));
return s;
}
if (Array.isArray(node)) {
return node.map((v, i) => revive(v, [...path, String(i)]));
}
if (node !== null && typeof node === 'object') {
const out = {};
for (const k of Object.keys(node)) out[k] = revive(node[k], [...path, k]);
return out;
}
return node;
}
return revive(json, []);
}
module.exports = { serialize, deserialize };
The shift from the naive version is that we never trust JSON.stringify to understand our types — we do the understanding ourselves in walk, and leave JSON.stringify only the JSON-safe output. Each non-obvious choice is worth a moment.
Why a recursive walk instead of a replacer. A replacer runs after the host has already called each value's toJSON, so by the time it sees a Date the date is already a string. Walking the value ourselves means we test node instanceof Date against the real object, before any conversion. It also gives us the node's path, which the meta channel needs.
Why the path is built as an array and joined with ".". The path uniquely locates a node in the tree: ["meta", "id"] becomes the key "meta.id". On deserialize we rebuild the same path as we descend, look it up in meta, and if there's a tag we know this exact node was special. The empty path "" (from [].join('.')) is the key for a top-level special value — which is how serialize(undefined) round-trips.
Why Map entries are indexed positionally (0k, 0v, 1k, ...). A Map's keys can be anything — objects, numbers, other special values — so we can't use the key itself in the path string. Instead we number the entries: the first entry's key is at path-suffix 0k, its value at 0v. This keeps every path a plain string and lets a Map key itself be a special type that gets its own meta entry. forEach on the stand-in array preserves order, so insertion order survives.
Why undefined, NaN, and the infinities become null (or a string) in json. JSON has no token for any of them — JSON.stringify(undefined) is literally undefined (not a string), and NaN/Infinity stringify to null. We substitute a placeholder that is valid JSON (null, or the digit string for BigInt) so the json channel always parses, and rely entirely on the meta tag to restore the real value. The placeholder's own value is irrelevant — meta overrides it.
Why deserialize checks meta before inspecting the node. The tag is the source of truth. A node that is null in json could be a genuine null, an undefined, a NaN, or an Infinity — only meta[path] disambiguates. So we look up the tag first; if there's no tag, we fall through to the structural cases (array, object) or return the node as-is.
Take a concrete value with a Date and a Map nested inside an object inside an array — the shape from the examples:
const value = [
{ when: new Date(0), data: new Map([['total', 42n]]) },
];
Serialize. walk starts at the root with path = [], key "". The root is an array, so it maps over its one element at path ["0"]. That element is a plain object, so we walk its keys:
walk(value, []) root is an array → map element 0
walk(elem, ["0"]) plain object → walk keys "when", "data"
walk(Date(0), ["0","when"])
instanceof Date → meta["0.when"] = "Date"
return "1970-01-01T00:00:00.000Z"
walk(Map, ["0","data"])
instanceof Map → meta["0.data"] = "Map"
entry 0: walk("total", ["0","data","0k"]) → "total" (plain string)
walk(42n, ["0","data","0v"]) → meta["0.data.0v"]="bigint", "42"
return [ ["total", "42"] ]
After the walk, the two channels are:
json = [ { when: "1970-01-01T00:00:00.000Z", data: [ ["total", "42"] ] } ]
meta = { "0.when": "Date", "0.data": "Map", "0.data.0v": "bigint" }
and serialize returns JSON.stringify({ json, meta }) — one valid JSON string.
Deserialize. We JSON.parse the string back into { json, meta }, then revive(json, []):
revive(json, []) key "" → no tag; it's an array → map element 0
revive(elem, ["0"]) key "0" → no tag; plain object → revive keys
revive("1970-...Z", ["0","when"])
meta["0.when"] === "Date" → return new Date("1970-...Z")
revive([["total","42"]], ["0","data"])
meta["0.data"] === "Map" → new Map()
entry 0: revive("total", ["0","data","0k"]) → "total" (no tag)
revive("42", ["0","data","0v"]) → meta says "bigint" → 42n
m.set("total", 42n); return m
The result is [ { when: <Date 1970-01-01>, data: Map { "total" => 42n } } ] — a real Date, a real Map, a real bigint, structure and order intact. The walk down and the walk back are mirror images; the only thing that crosses between them is the meta map.
{ "$type": "Date", value: "..." }, a user object that legitimately has a $type field gets revived as a fake Date and their data is corrupted. The fix is the separate meta channel keyed by path: type information never touches the data, so no user key can ever be mistaken for a tag. (The test 'a string that looks like a tagged placeholder is not mistaken for one' guards exactly this.)JSON.stringify throws on BigInt — it doesn't return null. Unlike NaN/Infinity (which become null), a stray BigInt anywhere in the value aborts the whole stringify with a TypeError. You must convert every bigint to a string before it reaches JSON.stringify, which is why walk catches typeof node === 'bigint' and returns node.toString().replacer sees a Date too late. JSON.stringify(date, replacer) calls date.toJSON() first, so the replacer receives the ISO string, and val instanceof Date is already false. Walking the value yourself is the only way to test instanceof Date against the real object.Map key in the path breaks for non-string keys. A Map can be keyed by an object or a number; you can't splice that into a "."-joined path string. Index entries positionally (0k/0v, 1k/1v) so the path is always a clean string — and so a special-typed key can carry its own meta entry too.Map/Set insertion order is part of the value. [...map.entries()] and [...set] both yield insertion order, and rebuilding with forEach + set/add preserves it. If you accidentally route entries through a plain object ({ key: value }), integer-like keys get reordered numerically and you lose the order. Keep entries in an array.null is ambiguous in json without meta. After substitution, a null in the json channel might be a real null, an undefined, a NaN, or an infinity. Deserialize must consult meta[path] before deciding — never infer the type from the placeholder, because the placeholder is deliberately lossy.serialize(undefined) must work: the path is [], the key is "", and meta[""] = "undefined". If your walk only records paths for nested values, top-level specials silently fall through. Seed the recursion with the empty path and the empty-string key handles itself.Decimal or a Temporal.ZonedDateTime): a name, a test, a serialize, and a deserialize. The meta channel stores the registered name as the tag, and deserialize looks the constructor up in the registry. The walk structure here doesn't change — only the set of recognised tags grows.Map, you get two distinct Maps back, and a circular reference (obj.self = obj) recurses forever. Real serializers keep a Map from already-seen object to an assigned id, emit a reference tag ({ $ref: 3 }) on the second sighting, and re-link them on revive. That turns the tree walk into a graph walk.RegExp, Uint8Array, ArrayBuffer, and friends are the same pattern as Date: a stand-in (the source/flags for a regex, a base64 string for bytes) plus a tag. Each is a few lines in walk and revive. Error objects are similar but trickier — you usually only round-trip name/message/stack, not the prototype chain.meta map can grow as large as the data when nearly every node is special. Production serializers prune redundant entries and can stream output rather than building one big string. If you ship this in a hot path, measure the meta size against the json size and consider a compact tag encoding (a single character per type) before optimising anything else.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
JSON.stringify and JSON.parse are the default way to send JavaScript values over the wire or stash them in localStorage. But JSON only knows about strings, numbers, booleans, null, plain objects, and arrays. Hand it a Date, a Map, a Set, or a BigInt and it quietly mangles them — a Date comes back as a string, a Map comes back as {}, and a BigInt makes JSON.stringify throw outright.
Implement serialize and deserialize so the round-trip preserves both structure and type. After deserialize(serialize(value)), a Date is still a Date (same instant), a Map is still a Map (same entries, same insertion order), a BigInt is still a bigint, and undefined / NaN / Infinity survive instead of being dropped or flattened to null.
// serialize: turn any supported value into a single JSON string.
function serialize(value: unknown): string;
// deserialize: turn that string back into the original value, types intact.
function deserialize(str: string): unknown;
// The contract that must hold for every supported value:
// deserialize(serialize(value)) deep-equals value
// ...and revived special values keep their type (instanceof Date, typeof bigint, ...).
The returned string must be valid JSON — JSON.parse(serialize(value)) must not throw. How you pack the type information into that string is up to you.
// A Date keeps its type and its exact instant.
const str = serialize(new Date('2026-06-04T12:30:00.000Z'));
const back = deserialize(str);
back instanceof Date; // true
back.getTime(); // 1780576200000 (same instant)
// Plain JSON would have lost this:
JSON.parse(JSON.stringify(new Date())) instanceof Date; // false — it's a string
// A nested mixture: a Map (with an object value) inside an object inside an array.
const value = [
{ label: 'config', data: new Map([['retries', 3]]), when: new Date(0) },
];
const back = deserialize(serialize(value));
back[0].data instanceof Map; // true
back[0].data.get('retries'); // 3
back[0].when instanceof Date; // true
// And the values JSON silently eats:
deserialize(serialize({ a: undefined, b: NaN, c: Infinity, d: 9n }));
// → { a: undefined, b: NaN, c: Infinity, d: 9n } (all four preserved)
Date, Map, Set, BigInt, undefined, NaN, Infinity, -Infinity, and any nesting of these inside plain objects and arrays. Primitives (string, finite number, boolean, null) pass through unchanged.Map or Set must iterate its entries in the same order they were inserted.undefined must survive everywhere — as the whole value, as an object property (the key stays present), and as an array element (the slot stays at its index).Map) — those are out of scope here, though a few appear under Going further in the solution.You'll write serialize and deserialize that move a rich JavaScript value through a JSON string and back without losing the value's types.
You have state in memory — a Date, a Map, a BigInt — and you need to get it somewhere JSON lives: a fetch body, a localStorage entry, a React Server Component payload streamed to the browser. The moment you call JSON.stringify, JSON quietly flattens anything it doesn't recognise. The Date becomes a string. The Map becomes {}. The BigInt makes stringify throw. On the other side you JSON.parse and get back a shape that looks right but has lost every non-JSON type. superjson is the wrapper that fixes this: it remembers what each special value was, ships that alongside the data, and rebuilds the real types on arrival.
The core idea is to split the output into two channels that travel together inside one string. The first channel, json, is a JSON-safe clone of your value — every special type swapped for a stand-in JSON already understands (a Date becomes its ISO string, a Map becomes an array of entries, a BigInt becomes a string of digits). The second channel, meta, is a small map from a path — the location of a node inside the tree — to a type tag like "Date" or "bigint". Serializing means walking the value to build both channels; deserializing means parsing them back and using meta to revive each tagged path into its real type.
The reason the type tag lives in a separate channel — keyed by path — rather than inline next to the data is subtle but important. If you instead tagged values inline, say by wrapping every Date as { "$type": "Date", "value": "..." }, then any plain object in the user's own data that happened to have a $type key would be misread as a tag on the way back. Keeping the metadata out of the data entirely means a user string can never be confused for a marker. That is the single design decision the whole approach turns on.
The obvious version is to do nothing special at all — just hand the value to JSON.stringify and trust JSON.parse to give it back:
function serialize(value) {
return JSON.stringify(value);
}
function deserialize(str) {
return JSON.parse(str);
}
This is what most code ships before someone files the bug. It works perfectly for plain objects, arrays, strings, numbers, and booleans — and fails for everything this question is about. serialize(new Date(0)) returns '"1970-01-01T00:00:00.000Z"', and deserialize of that is a plain string, not a Date. serialize(new Map([['k', 1]])) returns '{}' — JSON.stringify doesn't know how to read a Map's entries, so it serializes its (empty) enumerable own properties. serialize(10n) doesn't even return; JSON.stringify throws TypeError: Do not know how to serialize a BigInt. And { a: undefined } round-trips to {} — the key vanishes. The naive version isn't wrong in spirit; it's just blind to every type JSON doesn't natively model.
A tempting second attempt is to special-case types inline with a replacer function and a sentinel wrapper:
function serialize(value) {
return JSON.stringify(value, (key, val) => {
if (val instanceof Date) return { __date: val.toISOString() };
if (typeof val === 'bigint') return { __bigint: val.toString() };
return val;
});
}
This is closer, and a lot of homegrown solutions stop here. But it has two real flaws. First, the replacer receives values after Date has already been converted — a Date's toJSON runs before your replacer sees it, so val instanceof Date is already false and the __date branch never fires. Second, and more fundamentally, the sentinel { __date: ... } lives inside the data: if a user's own object has a __date key, deserializing will revive it as a Date and corrupt their data. The fix for both is to walk the value ourselves (so we see real Dates before any toJSON) and to keep the type tags in a separate channel (so they can never collide with user keys).
// Special values JSON can't represent natively get replaced by a JSON-safe
// stand-in, and their type is recorded in a separate `meta` map keyed by the
// value's PATH inside the tree. We serialize { json, meta } together as one
// string; on the way back we parse it, then walk `meta` to revive each path.
function isPlainObject(v) {
return v !== null && typeof v === 'object' && !Array.isArray(v)
&& !(v instanceof Date) && !(v instanceof Map) && !(v instanceof Set);
}
function serialize(value) {
const meta = {}; // path string -> type tag, e.g. "a.b" -> "Date"
// Walk the value, returning a JSON-safe clone and recording special types in
// `meta`. `path` is the array of keys from the root to the current node.
function walk(node, path) {
const key = path.join('.');
if (node === undefined) {
meta[key] = 'undefined';
return null; // JSON drops undefined; stash null and remember the truth
}
if (typeof node === 'bigint') {
meta[key] = 'bigint';
return node.toString(); // JSON.stringify throws on bigint; store digits
}
if (typeof node === 'number' && !Number.isFinite(node)) {
// NaN, Infinity, -Infinity all serialize to null in plain JSON.
meta[key] = Number.isNaN(node) ? 'NaN' : (node > 0 ? 'Infinity' : '-Infinity');
return null;
}
if (node instanceof Date) {
meta[key] = 'Date';
return node.toISOString();
}
if (node instanceof Map) {
meta[key] = 'Map';
// Recurse into each value, indexing entries positionally so the path is
// stable regardless of what the keys are.
return [...node.entries()].map(([k, v], i) => [
walk(k, [...path, `${i}k`]),
walk(v, [...path, `${i}v`]),
]);
}
if (node instanceof Set) {
meta[key] = 'Set';
return [...node].map((v, i) => walk(v, [...path, String(i)]));
}
if (Array.isArray(node)) {
return node.map((v, i) => walk(v, [...path, String(i)]));
}
if (isPlainObject(node)) {
const out = {};
for (const k of Object.keys(node)) out[k] = walk(node[k], [...path, k]);
return out;
}
return node; // string, finite number, boolean, null — JSON-safe already
}
const json = walk(value, []);
return JSON.stringify({ json, meta });
}
function deserialize(str) {
const { json, meta } = JSON.parse(str);
// Revive a node given its path. We consult `meta` for this exact path first,
// because the type tag tells us what the JSON stand-in really represents.
function revive(node, path) {
const key = path.join('.');
const tag = meta[key];
if (tag === 'undefined') return undefined;
if (tag === 'bigint') return BigInt(node);
if (tag === 'NaN') return NaN;
if (tag === 'Infinity') return Infinity;
if (tag === '-Infinity') return -Infinity;
if (tag === 'Date') return new Date(node);
if (tag === 'Map') {
const m = new Map();
node.forEach(([k, v], i) => {
m.set(revive(k, [...path, `${i}k`]), revive(v, [...path, `${i}v`]));
});
return m;
}
if (tag === 'Set') {
const s = new Set();
node.forEach((v, i) => s.add(revive(v, [...path, String(i)])));
return s;
}
if (Array.isArray(node)) {
return node.map((v, i) => revive(v, [...path, String(i)]));
}
if (node !== null && typeof node === 'object') {
const out = {};
for (const k of Object.keys(node)) out[k] = revive(node[k], [...path, k]);
return out;
}
return node;
}
return revive(json, []);
}
module.exports = { serialize, deserialize };
The shift from the naive version is that we never trust JSON.stringify to understand our types — we do the understanding ourselves in walk, and leave JSON.stringify only the JSON-safe output. Each non-obvious choice is worth a moment.
Why a recursive walk instead of a replacer. A replacer runs after the host has already called each value's toJSON, so by the time it sees a Date the date is already a string. Walking the value ourselves means we test node instanceof Date against the real object, before any conversion. It also gives us the node's path, which the meta channel needs.
Why the path is built as an array and joined with ".". The path uniquely locates a node in the tree: ["meta", "id"] becomes the key "meta.id". On deserialize we rebuild the same path as we descend, look it up in meta, and if there's a tag we know this exact node was special. The empty path "" (from [].join('.')) is the key for a top-level special value — which is how serialize(undefined) round-trips.
Why Map entries are indexed positionally (0k, 0v, 1k, ...). A Map's keys can be anything — objects, numbers, other special values — so we can't use the key itself in the path string. Instead we number the entries: the first entry's key is at path-suffix 0k, its value at 0v. This keeps every path a plain string and lets a Map key itself be a special type that gets its own meta entry. forEach on the stand-in array preserves order, so insertion order survives.
Why undefined, NaN, and the infinities become null (or a string) in json. JSON has no token for any of them — JSON.stringify(undefined) is literally undefined (not a string), and NaN/Infinity stringify to null. We substitute a placeholder that is valid JSON (null, or the digit string for BigInt) so the json channel always parses, and rely entirely on the meta tag to restore the real value. The placeholder's own value is irrelevant — meta overrides it.
Why deserialize checks meta before inspecting the node. The tag is the source of truth. A node that is null in json could be a genuine null, an undefined, a NaN, or an Infinity — only meta[path] disambiguates. So we look up the tag first; if there's no tag, we fall through to the structural cases (array, object) or return the node as-is.
Take a concrete value with a Date and a Map nested inside an object inside an array — the shape from the examples:
const value = [
{ when: new Date(0), data: new Map([['total', 42n]]) },
];
Serialize. walk starts at the root with path = [], key "". The root is an array, so it maps over its one element at path ["0"]. That element is a plain object, so we walk its keys:
walk(value, []) root is an array → map element 0
walk(elem, ["0"]) plain object → walk keys "when", "data"
walk(Date(0), ["0","when"])
instanceof Date → meta["0.when"] = "Date"
return "1970-01-01T00:00:00.000Z"
walk(Map, ["0","data"])
instanceof Map → meta["0.data"] = "Map"
entry 0: walk("total", ["0","data","0k"]) → "total" (plain string)
walk(42n, ["0","data","0v"]) → meta["0.data.0v"]="bigint", "42"
return [ ["total", "42"] ]
After the walk, the two channels are:
json = [ { when: "1970-01-01T00:00:00.000Z", data: [ ["total", "42"] ] } ]
meta = { "0.when": "Date", "0.data": "Map", "0.data.0v": "bigint" }
and serialize returns JSON.stringify({ json, meta }) — one valid JSON string.
Deserialize. We JSON.parse the string back into { json, meta }, then revive(json, []):
revive(json, []) key "" → no tag; it's an array → map element 0
revive(elem, ["0"]) key "0" → no tag; plain object → revive keys
revive("1970-...Z", ["0","when"])
meta["0.when"] === "Date" → return new Date("1970-...Z")
revive([["total","42"]], ["0","data"])
meta["0.data"] === "Map" → new Map()
entry 0: revive("total", ["0","data","0k"]) → "total" (no tag)
revive("42", ["0","data","0v"]) → meta says "bigint" → 42n
m.set("total", 42n); return m
The result is [ { when: <Date 1970-01-01>, data: Map { "total" => 42n } } ] — a real Date, a real Map, a real bigint, structure and order intact. The walk down and the walk back are mirror images; the only thing that crosses between them is the meta map.
{ "$type": "Date", value: "..." }, a user object that legitimately has a $type field gets revived as a fake Date and their data is corrupted. The fix is the separate meta channel keyed by path: type information never touches the data, so no user key can ever be mistaken for a tag. (The test 'a string that looks like a tagged placeholder is not mistaken for one' guards exactly this.)JSON.stringify throws on BigInt — it doesn't return null. Unlike NaN/Infinity (which become null), a stray BigInt anywhere in the value aborts the whole stringify with a TypeError. You must convert every bigint to a string before it reaches JSON.stringify, which is why walk catches typeof node === 'bigint' and returns node.toString().replacer sees a Date too late. JSON.stringify(date, replacer) calls date.toJSON() first, so the replacer receives the ISO string, and val instanceof Date is already false. Walking the value yourself is the only way to test instanceof Date against the real object.Map key in the path breaks for non-string keys. A Map can be keyed by an object or a number; you can't splice that into a "."-joined path string. Index entries positionally (0k/0v, 1k/1v) so the path is always a clean string — and so a special-typed key can carry its own meta entry too.Map/Set insertion order is part of the value. [...map.entries()] and [...set] both yield insertion order, and rebuilding with forEach + set/add preserves it. If you accidentally route entries through a plain object ({ key: value }), integer-like keys get reordered numerically and you lose the order. Keep entries in an array.null is ambiguous in json without meta. After substitution, a null in the json channel might be a real null, an undefined, a NaN, or an infinity. Deserialize must consult meta[path] before deciding — never infer the type from the placeholder, because the placeholder is deliberately lossy.serialize(undefined) must work: the path is [], the key is "", and meta[""] = "undefined". If your walk only records paths for nested values, top-level specials silently fall through. Seed the recursion with the empty path and the empty-string key handles itself.Decimal or a Temporal.ZonedDateTime): a name, a test, a serialize, and a deserialize. The meta channel stores the registered name as the tag, and deserialize looks the constructor up in the registry. The walk structure here doesn't change — only the set of recognised tags grows.Map, you get two distinct Maps back, and a circular reference (obj.self = obj) recurses forever. Real serializers keep a Map from already-seen object to an assigned id, emit a reference tag ({ $ref: 3 }) on the second sighting, and re-link them on revive. That turns the tree walk into a graph walk.RegExp, Uint8Array, ArrayBuffer, and friends are the same pattern as Date: a stand-in (the source/flags for a regex, a base64 string for bytes) plus a tag. Each is a few lines in walk and revive. Error objects are similar but trickier — you usually only round-trip name/message/stack, not the prototype chain.meta map can grow as large as the data when nearly every node is special. Production serializers prune redundant entries and can stream output rather than building one big string. If you ship this in a hot path, measure the meta size against the json size and consider a compact tag encoding (a single character per type) before optimising anything else.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.