Implement JSON.stringify
A honest subset of JSON.stringify: primitives, arrays, objects, toJSON, skipped functions/undefined, and why cycles throw TypeError.
- 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 intonull.NaN,Infinity, and-Infinitybecomenull. They are never omitted.- Cycles throw
TypeError.BigIntthrowsTypeError. Top-levelundefinedreturnsundefined, not a string.- Call
toJSONwhen it exists. Do not claimreplacerorspaceunless 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.
replacer. Function form ((key, value) => next) and whitelist-array form (['id', 'name']). Both run on the root with key''first.space. Pretty-print indent, number clamped to 10, string truncated to 10.- 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). toJSON’skeyargument. The real call istoJSON(key)with the property name, the array index as a string, or''at the root.JSON.rawJSON. A later addition; raw fragments splice in as already-valid JSON text.- 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
- If
toJSONexists, replace the value with its return. - Dispatch on type: primitives,
null, throw onbigint,undefinedfor skip-types. - Before walking an object, if it is already on the stack, throw
TypeError. - Arrays: serialize
lengthslots, missing and skip-types asnull. - Objects:
Object.keys, omit keys whose serialize returnedundefined. - Quote strings (control chars,
",\). Pop the stack after the node.
Interview out-loud answer
I would implement a subset and name the gaps.
toJSONruns first, which is whyDatebecomes an ISO string. Object keys withundefinedor functions disappear; the same values in arrays becomenull.NaNandInfinitybecomenulltoo, but those keys stay. ASetof objects on the current path throwsTypeErroron a cycle.BigIntthrows. I am not doingreplacerorspaceunless you ask. Top-levelundefinedreturnsundefined, not a string.
Related on this site
- JSON parse and stringify pitfalls: native footguns this subset is aiming at
- Flatten and Unflatten Objects: a different tree codec, also cycle-aware
- Deep vs Shallow Copy: why stringify is a bad clone
- Structured Clone: the platform deep copy, cycles allowed
- Error Types and Custom Errors:
TypeErrorvsSyntaxErroron parse
Further reading
Related guides
- Array.filter PolyfillWrite filter that skips holes, respects thisArg, and never writes empty slots for rejected items. Contrast with map’s hole-preserving result.
- Array.find PolyfillWrite Array.prototype.find: first callback hit wins, holes are read as undefined, nothing matching returns undefined, and findIndex is the index twin.
- Array.flat PolyfillImplement flat(depth) with Infinity, holes, and non-array array-likes. Recursion vs stack, and why concat(spread) blows up on huge inputs.
- Array.forEach PolyfillImplement Array.prototype.forEach: visit present elements, skip holes, ignore the callback return, and honor thisArg without using native forEach.
- Array.includes PolyfillImplement Array.prototype.includes with SameValueZero: NaN is found, +0 equals -0, and fromIndex can walk backwards from the end.