ESC

Type to search the knowledge base.

Implement JSON.stringify

A honest subset of JSON.stringify: primitives, arrays, objects, toJSON, skipped functions/undefined, and why cycles throw TypeError.

advanced6 min read
  • javascript
  • json
  • polyfill
  • serialization

A home-rolled stringify that dumps '{a:1}' (unquoted keys) or keeps undefined in objects is not JSON. Interviewers use this prompt to see whether you know the value rules, not whether you can pretty-print. The real function is a long algorithm. This page is a subset. It names what it omits.

Native surprises you must match: object keys whose values are undefined or functions disappear. The same values in an array become null. Cycles throw TypeError. toJSON runs first (Date becomes an ISO string because Date.prototype.toJSON exists).

Key takeaways

  • Objects omit undefined, functions, and symbol-keyed properties. Arrays turn those slots into null.
  • NaN, Infinity, and -Infinity become null. They are never omitted.
  • Cycles throw TypeError. BigInt throws TypeError. Top-level undefined returns undefined, not a string.
  • Call toJSON when it exists. Do not claim replacer or space unless you implement them.

What becomes null, what disappears, what throws

Value In an object In an array At the root
null null null 'null'
true / false / finite number / string as JSON as JSON as JSON
NaN, ±Infinity null null 'null'
undefined, function, symbol key omitted null undefined (no string)
BigInt throw throw throw
cycle throw throw throw
JSON.stringify({ a: 1, b: undefined, c: () => {}, d: Symbol('x') });
// '{"a":1}'

JSON.stringify([1, undefined, () => {}, Symbol('x')]);
// '[1,null,null,null]'

JSON.stringify(undefined); // undefined, not '"undefined"'

Date is an object with toJSON:

JSON.stringify({ when: new Date('2026-11-08T00:00:00.000Z') });
// '{"when":"2026-11-08T00:00:00.000Z"}'

Maps and sets have no enumerable own data keys, so they become '{}'. That is correct JSON and a bad clone. Use structured clone when you wanted the contents.

Subset implementation

This covers primitives, arrays, plain objects, toJSON, cycle detection, string escaping, BigInt throw. It does not implement the rest of the spec (list below).

function quote(str) {
  return `"${String(str).replace(/[\\"\u0000-\u001f]/g, (ch) => {
    const table = {
      '"': '\\"',
      '\\': '\\\\',
      '\b': '\\b',
      '\f': '\\f',
      '\n': '\\n',
      '\r': '\\r',
      '\t': '\\t',
    };
    if (table[ch]) return table[ch];
    const hex = ch.charCodeAt(0).toString(16).padStart(4, '0');
    return `\\u${hex}`;
  })}"`;
}

function jsonStringify(value) {
  const stack = new Set();

  function serialize(val) {
    if (val !== null && typeof val === 'object' && typeof val.toJSON === 'function') {
      val = val.toJSON();
    }

    if (val === null) return 'null';

    const type = typeof val;
    if (type === 'boolean') return val ? 'true' : 'false';
    if (type === 'number') return Number.isFinite(val) ? String(val) : 'null';
    if (type === 'string') return quote(val);
    if (type === 'bigint') {
      throw new TypeError('Do not know how to serialize a BigInt');
    }
    if (type === 'undefined' || type === 'function' || type === 'symbol') {
      return undefined; // caller omits or writes null
    }
    if (type !== 'object') return undefined;

    if (stack.has(val)) {
      throw new TypeError('Converting circular structure to JSON');
    }
    stack.add(val);

    let out;
    if (Array.isArray(val)) {
      const parts = [];
      for (let i = 0; i < val.length; i++) {
        const cell = i in val ? val[i] : null; // holes → null
        const serialized = serialize(cell);
        parts.push(serialized === undefined ? 'null' : serialized);
      }
      out = `[${parts.join(',')}]`;
    } else {
      const parts = [];
      for (const key of Object.keys(val)) {
        const serialized = serialize(val[key]);
        if (serialized !== undefined) {
          parts.push(`${quote(key)}:${serialized}`);
        }
      }
      out = `{${parts.join(',')}}`;
    }

    stack.delete(val);
    return out;
  }

  return serialize(value);
}
jsonStringify({ a: 1, skip: undefined, list: [2, undefined] });
// '{"a":1,"list":[2,null]}'

const loop = {};
loop.self = loop;
jsonStringify(loop); // TypeError

Object.keys is own enumerable string keys, which is the spec visit order for ordinary objects. Symbol keys never appear. Non-enumerable fields never appear. Inherited enumerable keys never appear.

Holes in arrays serialize as null, unlike flat, which drops them. [1, , 3] → '[1,null,3]'.

What this subset omits

Say this list out loud so you are not claiming a full polyfill.

  1. replacer. Function form ((key, value) => next) and whitelist-array form (['id', 'name']). Both run on the root with key '' first.
  2. space. Pretty-print indent, number clamped to 10, string truncated to 10.
  3. Wrapper objects. new Number(1), new String('x'), new Boolean(false) convert to primitives before serialize. This sketch treats them as objects (Object.keys → '{}' unless they boxed something enumerable).
  4. toJSON’s key argument. The real call is toJSON(key) with the property name, the array index as a string, or '' at the root.
  5. JSON.rawJSON. A later addition; raw fragments splice in as already-valid JSON text.
  6. Unicode extras. Some engines also escape U+2028 / U+2029 in strings for old script-safety. Not required to pass a typical interview fixture.

If they ask you to add replacer, wrap serialize so every property value goes through the function, including the root. Returning undefined from a replacer omits an object key and still becomes null in an array.

Cycles vs shared structure

The stack is the current path, not “every object ever seen.” Delete after finishing a node so a diamond ({ a: x, b: x }) stringifies twice (correct). A back-edge (x.self = x) hits stack.has and throws.

JSON.parse(JSON.stringify(obj)) is still a lossy clone: dates become strings, undefined keys vanish, Map becomes {}. That is why this helper is a serializer, not a copier. Pitfalls on the native API: JSON parse and stringify pitfalls.

Procedure

  1. If toJSON exists, replace the value with its return.
  2. Dispatch on type: primitives, null, throw on bigint, undefined for skip-types.
  3. Before walking an object, if it is already on the stack, throw TypeError.
  4. Arrays: serialize length slots, missing and skip-types as null.
  5. Objects: Object.keys, omit keys whose serialize returned undefined.
  6. Quote strings (control chars, ", \). Pop the stack after the node.

Interview out-loud answer

I would implement a subset and name the gaps. toJSON runs first, which is why Date becomes an ISO string. Object keys with undefined or functions disappear; the same values in arrays become null. NaN and Infinity become null too, but those keys stay. A Set of objects on the current path throws TypeError on a cycle. BigInt throws. I am not doing replacer or space unless you ask. Top-level undefined returns undefined, not a string.

Further reading

Related guides