advancedCommon6 min read · Updated Jul 18, 2026

Implement JSON.stringify from Scratch

Implement JSON.stringify in JavaScript: undefined vs null rules, toJSON, string escaping, NaN, circular detection that allows shared references, replacer, and space.


The problem

Implement stringify(value) matching JSON.stringify — including the rules people think they know: what happens to undefined, 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:

ValueIn an object propertyIn an arrayTop-level
undefined / function / symbolproperty omittedbecomes nullreturns undefined
NaN / Infinitynullnull"null"
Datevia toJSON → ISO stringsamesame
BigIntthrows TypeErrorthrowsthrows
Symbol-keyed propertyskipped
Circular referencethrows TypeErrorthrowsthrows

Implementation

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);
}

Verified behavior

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 });                         // TypeError

Circular vs shared references — the discriminating detail

Most 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" — WRONG

A 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 ✓

Edge cases interviewers probe

  • toJSON before everythingDate serializes as an ISO string only because Date.prototype.toJSON exists. Skip this hook and dates become {}. Any object can define its own.
  • String escaping — quotes, backslashes, and all control chars \u0000\u001F. Without quote(), stringify('a"b') emits "a"b" — invalid JSON that explodes at parse time on someone else's service.
  • The three-way undefined rule (table above) — omitted in objects, null in arrays, undefined at top level. Reciting all three without prompting usually ends the question early.
  • Replacer subtleties — runs after 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.
  • Key orderingObject.keys order: integer-like keys ascending first, then insertion order. Explains why stringify({ b: 1, 2: 2, a: 3 }) starts with "2".

Common mistakes

  • Emitting "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.
  • No string escaping at all (the most common omission, and the most dangerous — it's a correctness and injection issue).
  • The never-deleted WeakSet false-circular bug (section above).
  • Forgetting toJSON, then confidently claiming dates "just work."
  • Recursing with JSON.parse(JSON.stringify(...)) somewhere inside your "from scratch" implementation. It happens. Interviewers notice.

Follow-up questions

  • "Why does JSON omit undefined in objects but null it in arrays?" — omitting an array element would shift every index after it; objects have no such positional constraint.
  • "How would you serialize a 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.
  • "What's the complexity?" — O(n) over reachable values; the ancestor set is O(depth). String concatenation via array-join keeps it linear.
  • "How is this related to deep clone?"JSON.parse(JSON.stringify(x)) inherits every limitation in the table above; that's the argument for a real deep clone.
  • "Now write the inverse."implement JSON.parse, which is the tokenizer plus a recursive-descent grammar.

  • JSON.parse

    advanced

    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.

  • Object.is

    beginner

    The equality that fixes === for NaN and -0: tiny implementation, classic trivia follow-ups.