JSON.parse
advancedWrite a small recursive-descent parser — the deepest polyfill question in the set.
Implement JSON.stringify in JavaScript: undefined vs null rules, toJSON, string escaping, NaN, circular detection that allows shared references, replacer, and space.
Implement
stringify(value)matchingJSON.stringify— including the rules people think they know: what happens toundefined, functions,NaN,Dates, symbol keys, and circular structures.
This is one of the highest-signal polyfill questions because the spec behavior is a table of asymmetries, and each asymmetry is a follow-up. Before writing code, put the table on the whiteboard — it is the answer:
| Value | In an object property | In an array | Top-level |
|---|---|---|---|
undefined / function / symbol | property omitted | becomes null | returns undefined |
NaN / Infinity | null | null | "null" |
Date | via toJSON → ISO string | same | same |
BigInt | throws TypeError | throws | throws |
| Symbol-keyed property | skipped | — | — |
| Circular reference | throws TypeError | throws | throws |
function stringify(value, replacer, space) {
// ---- normalize `space` into a gap string (max 10 chars, per spec) ----
let gap = "";
if (typeof space === "number") gap = " ".repeat(Math.max(0, Math.min(10, Math.trunc(space))));
else if (typeof space === "string") gap = space.slice(0, 10);
const replacerFn = typeof replacer === "function" ? replacer : undefined;
// Array replacer = allow-list of property names
const allowList = Array.isArray(replacer)
? new Set(replacer.filter((k) => typeof k === "string" || typeof k === "number").map(String))
: undefined;
// Objects on the CURRENT PATH only — see "circular vs shared" below
const ancestors = new Set();
const ESCAPES = {
'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f",
"\n": "\\n", "\r": "\\r", "\t": "\\t",
};
function quote(str) {
// Escape quotes, backslashes, and control chars — the step most
// hand-rolled versions skip, producing invalid JSON.
return '"' + str.replace(/["\\\u0000-\u001F]/g, (ch) =>
ESCAPES[ch] ?? "\\u" + ch.charCodeAt(0).toString(16).padStart(4, "0")
) + '"';
}
function serialize(key, holder, depth) {
let value = holder[key];
// 1. toJSON hook — this is the entire reason Dates work
if (value !== null && typeof value === "object" && typeof value.toJSON === "function") {
value = value.toJSON(key);
}
// 2. replacer runs AFTER toJSON, with holder as `this`
if (replacerFn) {
value = replacerFn.call(holder, key, value);
}
// 3. unwrap boxed primitives (new Number(3) → 3)
if (value instanceof Number) value = Number(value);
else if (value instanceof String) value = String(value);
else if (value instanceof Boolean) value = value.valueOf();
switch (typeof value) {
case "string": return quote(value);
case "number": return Number.isFinite(value) ? String(value) : "null";
case "boolean": return String(value);
case "bigint": throw new TypeError("Do not know how to serialize a BigInt");
case "undefined":
case "function":
case "symbol":
return undefined; // caller decides: omit (object) or "null" (array)
}
if (value === null) return "null";
// ---- objects & arrays ----
if (ancestors.has(value)) {
throw new TypeError("Converting circular structure to JSON");
}
ancestors.add(value);
const open = gap ? "\n" + gap.repeat(depth + 1) : "";
const close = gap ? "\n" + gap.repeat(depth) : "";
const colon = gap ? ": " : ":";
let result;
if (Array.isArray(value)) {
const items = [];
for (let i = 0; i < value.length; i++) {
// undefined/function/symbol → null INSIDE ARRAYS (holes too)
items.push(serialize(String(i), value, depth + 1) ?? "null");
}
result = items.length ? "[" + open + items.join("," + open) + close + "]" : "[]";
} else {
const entries = [];
for (const k of Object.keys(value)) { // own, enumerable, STRING keys only
if (allowList && !allowList.has(k)) continue;
const serialized = serialize(k, value, depth + 1);
if (serialized !== undefined) { // omit unserializable properties
entries.push(quote(k) + colon + serialized);
}
}
result = entries.length ? "{" + open + entries.join("," + open) + close + "}" : "{}";
}
ancestors.delete(value); // ← leaving this object's subtree
return result;
}
// The spec wraps the input in a fake holder so the root gets key ""
return serialize("", { "": value }, 0);
}stringify({ a: undefined, b: () => {}, c: 1 }); // '{"c":1}' — omitted
stringify([undefined, () => {}, 1]); // '[null,null,1]' — nulled
stringify(undefined); // undefined (not a string!)
stringify(NaN); // 'null'
stringify(new Date(0)); // '"1970-01-01T00:00:00.000Z"' — via toJSON
stringify('say "hi"\n'); // '"say \\"hi\\"\\n"' — escaped
stringify({ [Symbol("k")]: 1, ok: 2 }); // '{"ok":2}' — symbol keys skipped
stringify({ big: 10n }); // TypeErrorMost hand-rolled versions track visited objects in one WeakSet and never remove them. That doesn't detect cycles — it detects revisits, and rejects perfectly valid structures:
const shared = { x: 1 };
const obj = { a: shared, b: shared }; // a DAG, not a cycle
JSON.stringify(obj); // '{"a":{"x":1},"b":{"x":1}}' — fine
naiveStringify(obj); // ❌ TypeError "circular" — WRONGA cycle means an object appears on its own ancestor path. That's why the implementation adds the object before recursing and — the part everyone forgets — deletes it after. Interviewers plant the shared-reference input specifically to catch the missing delete; getting this right unprompted is the strongest signal in the question.
const a = {};
a.self = a;
stringify(a); // TypeError: Converting circular structure to JSON ✓toJSON before everything — Date serializes as an ISO string only because Date.prototype.toJSON exists. Skip this hook and dates become {}. Any object can define its own.\u0000–\u001F. Without quote(), stringify('a"b') emits "a"b" — invalid JSON that explodes at parse time on someone else's service.undefined rule (table above) — omitted in objects, null in arrays, undefined at top level. Reciting all three without prompting usually ends the question early.toJSON, receives (key, value) with the holder as this, and the root call gets key "". An array replacer is an allow-list of keys instead.space clamping — numbers clamp to 0–10 spaces; strings truncate to 10 chars. Trivia, but it's in the spec table interviewers read from.Object.keys order: integer-like keys ascending first, then insertion order. Explains why stringify({ b: 1, 2: 2, a: 3 }) starts with "2"."key":undefined — string-concatenating a recursive call that returned undefined. Instant invalid JSON.String(NaN) → NaN in the output. JSON has no NaN; the spec says null.WeakSet false-circular bug (section above).toJSON, then confidently claiming dates "just work."JSON.parse(JSON.stringify(...)) somewhere inside your "from scratch" implementation. It happens. Interviewers notice.undefined in objects but null it in arrays?" — omitting an array element would shift every index after it; objects have no such positional constraint.Map or Set?" — they stringify as {} (no enumerable own props, no toJSON). Options: a custom toJSON, a replacer that converts to arrays, or structuredClone if the goal is cloning rather than JSON.JSON.parse(JSON.stringify(x)) inherits every limitation in the table above; that's the argument for a real deep clone.Write a small recursive-descent parser — the deepest polyfill question in the set.
Reimplement typeof with Object.prototype.toString and learn why typeof null === 'object'.
Implement reduce including the no-initial-value case and empty-array TypeError.
The equality that fixes === for NaN and -0: tiny implementation, classic trivia follow-ups.