The medium myJsonStringify handles the type-dispatch core — primitives, arrays, objects, the undefined-triple rule. This version layers on the three spec features production code depends on: a replacer (filter or transform), an indent (pretty-printing), and circular-reference detection that throws like native JSON.stringify. Defer to the medium version for the basics; this question is about layering the new features cleanly on top.
function myJsonStringifyII(
value: unknown,
replacer?: ((key: string, value: unknown) => unknown) | string[] | null,
indent?: number | string | null,
): string | undefined;
// Compact mode (same behaviour as the medium version).
myJsonStringifyII({ a: 1, b: 2 });
// '{"a":1,"b":2}'
// Replacer as an allow-list array — only listed keys survive.
myJsonStringifyII({ a: 1, b: 2, c: 3 }, ['a', 'c']);
// '{"a":1,"c":3}'
// Replacer as a function — runs for every key/value pair, return value
// is what gets serialized. Return undefined to drop a key.
myJsonStringifyII({ a: 1, b: 2 }, (key, value) =>
typeof value === 'number' ? value * 10 : value,
);
// '{"a":10,"b":20}'
// Numeric indent — newlines between entries, `indent` spaces per level.
myJsonStringifyII({ a: 1, b: [2, 3] }, null, 2);
// '{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}'
// String indent — used verbatim (clipped to 10 chars).
myJsonStringifyII({ a: 1 }, null, '\t');
// '{\n\t"a": 1\n}'
// Circular reference — throws, just like native JSON.stringify.
const a = {};
a.self = a;
myJsonStringifyII(a);
// TypeError: Converting circular structure to JSON
replacer is an array, treat it as a string allow-list applied at every object (not just the root). If it's a function, call it with (key, value) for every key/value pair, including the root keyed as the empty string "". Anything else (including null/undefined) means no filtering.value. Returning undefined drops the key from an object (or becomes "null" inside an array, mirroring the medium version's array-item rule).n in [1, 10] becomes n spaces; numbers outside that range are clipped (above 10) or ignored (zero, negative, NaN). A string is used verbatim but clipped to its first 10 characters. Anything else means compact mode.{} and [] stay compact (no inner newline) even with indent set.toJSON hook — if a value's own .toJSON is a function, call it with the key and serialize its return value instead. This runs BEFORE the replacer (matches spec ordering).BigInt (would normally throw), wrapped primitive objects (new Number(1)), and the spec's "well-known symbol" branches.You'll keep the medium myJsonStringify type-dispatch core and bolt three orthogonal hooks onto it — a per-pair replacer, a depth-aware indent formatter, and a path-based cycle guard — without rewriting any of the existing branches.
The medium version handles type dispatch. It's enough for "convert this data to JSON," but not for what production code actually uses JSON.stringify for. You want to redact a password before logging — that's a replacer. You want a config dump humans can read — that's indent. You want to fail fast on a self-referencing object instead of blowing the stack — that's cycle detection. Each is a real engineering need, each is in the spec, and each composes cleanly on top of the type-dispatch core if wired in the right place. The trap is that the three look entangled at first; the lesson of this question is that they aren't.
Three hooks. Each one slots into the recursive walk at a specific moment and is independent of the other two.
One — the replacer transforms what gets serialized. Before any type dispatch runs, the replacer (if any) sees the current (key, value) pair and either returns a different value or returns undefined to drop it. Two flavours: as a function it's called for every pair; as an array it's a string allow-list applied at every plain object. Same parameter slot, two completely different calling conventions.
Two — the indent formatter decides spacing. Compact mode emits {"a":1,"b":2}; pretty mode emits {\n "a": 1,\n "b": 2\n}. Width is indent × depth, so every level adds one unit of padding. Empty {} and [] stay compact even with indent set — a small spec wrinkle.
Three — the cycle guard tracks ancestors on the current descent path. Entering an object adds it to an ancestors set; leaving removes it. If we try to enter an object already in the set, we've followed a back-edge — throw. Ancestors are the path I'm on now, not every object I've ever seen. A DAG (same object under two sibling branches) is fine; only a true cycle throws.
The piece beginners get wrong most often is the spec ordering inside a single call: toJSON runs first, then the replacer, then the type dispatch. Swap the first two and most date-aware replacers break — they're written expecting the ISO string the Date#toJSON produces, not the raw Date instance.
Two naive approaches show up reliably in this question. Both are wrong, and they're wrong in different ways.
The reflex is to crack open the medium myJsonStringify and try to handle the new arguments at every recursive call site:
function myJsonStringifyIIBad(value, replacer, indent) {
if (value === null) return 'null';
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'null';
// ...primitives copy/pasted from medium...
if (Array.isArray(value)) {
const items = value.map((item, i) => {
// Try to call replacer here. But what's the key? The index? A string?
// And how do I know my depth for indent? It isn't passed in!
const v = typeof replacer === 'function' ? replacer(String(i), item) : item;
return myJsonStringifyIIBad(v, replacer, indent);
});
return `[${items.join(',')}]`;
}
// ...same problem at the object branch...
}
The replacer needs the key the value was found under — which the medium function never carries. Adding key everywhere is workable, but depth is the deeper problem: indent grows linearly with nesting and the medium signature has no depth context either. You can patch each call site one by one, but the signature keeps growing and the branches tangle. Worse, the replacer-array form must filter at every object, adding a third branch. By the end you've doubled the line count and still don't have cycle detection. The right fix is one inner helper with the new parameters — key, ancestors, depth — wrapped by an outer function that initializes them.
seen Set for cycle detectionThe other reflex is to detect cycles with a global "have I seen this object?" set:
function withSeenSet(value) {
const seen = new Set();
function go(v) {
if (v && typeof v === 'object') {
if (seen.has(v)) throw new TypeError('Converting circular structure to JSON');
seen.add(v); // ← never removed
}
// ...dispatch...
}
return go(value);
}
Concrete failure:
const x = { n: 1 };
withSeenSet({ a: x, b: x });
// throws — but { a: x, b: x } is a DAG, not a cycle. Native JSON.stringify
// happily produces '{"a":{"n":1},"b":{"n":1}}' for this input.
"Have I seen this?" is a different question from "is this my ancestor right now?". When we finish serializing a and pop back up to the parent, x is no longer on our path — visiting it again under sibling b is fine. The seen-set approach forgets to pop and can't distinguish a back-edge (cycle) from a cross-edge (shared sub-structure). The fix is to track ancestors specifically: add on descent, remove on ascent, with try/finally to guarantee the removal even when a deeper call throws.
function myJsonStringifyII(value, replacer = null, indent = 0) {
// Normalize the indent ONCE up front. The recursion uses this many times
// per level, so spending a few cycles here saves recomputation later.
// Number path: a positive finite number, clipped to 10, becomes that many
// spaces. String path: used verbatim, but clipped to the first 10 chars.
// Anything else (null, undefined, 0, NaN, negative, oversized object) ->
// empty string, which is the "compact mode" flag the recursion checks.
let indentStr = '';
if (typeof indent === 'number' && Number.isFinite(indent) && indent > 0) {
indentStr = ' '.repeat(Math.min(10, Math.floor(indent)));
} else if (typeof indent === 'string') {
indentStr = indent.slice(0, 10);
}
// Split the replacer into two narrow hooks the recursion can branch on
// without re-checking the type each call. A Set gives O(1) `has` lookups
// for the array form; the function form is just a reference we can call.
const keyFilter = Array.isArray(replacer)
? new Set(replacer.map(String))
: null;
const replacerFn = typeof replacer === 'function' ? replacer : null;
function serialize(value, key, ancestors, depth) {
// toJSON hook fires BEFORE the replacer (spec ordering). If you swap
// these, replacer(k, dateValue) sees the Date object instead of the
// already-converted ISO string and most real-world replacers break.
if (
value &&
typeof value === 'object' &&
typeof value.toJSON === 'function'
) {
value = value.toJSON(key);
}
// Replacer-function hook: gets the final say on what value flows into
// the type dispatch. Returning undefined here drops the slot (the
// container branches below interpret undefined as "omit" or "null").
if (replacerFn) {
value = replacerFn.call({ [key]: value }, key, value);
}
if (value === null) return 'null';
switch (typeof value) {
case 'string':
return `"${escapeString(value)}"`;
case 'number':
// Spec: NaN and +/-Infinity have no JSON form — emit "null".
return Number.isFinite(value) ? String(value) : 'null';
case 'boolean':
return String(value);
case 'undefined':
case 'function':
case 'symbol':
// The "absent value" return — each container interprets this:
// object value -> key dropped; array item -> literal "null"; top
// level -> undefined propagates to the caller.
return undefined;
case 'object': {
// PATH-based cycle detection. `ancestors` holds objects on the
// current descent path, NOT every object ever seen. The DAG case
// (same object reachable via two non-overlapping branches) must
// still serialize — only true cycles throw.
if (ancestors.has(value)) {
throw new TypeError('Converting circular structure to JSON');
}
ancestors.add(value);
try {
// Precompute the per-level separators. `sep` opens after `{`/`[`,
// `closeSep` un-indents before the matching `}`/`]`, `joiner` sits
// between sibling entries. In compact mode all three collapse.
const sep = indentStr ? '\n' + indentStr.repeat(depth + 1) : '';
const closeSep = indentStr ? '\n' + indentStr.repeat(depth) : '';
const colon = indentStr ? ': ' : ':';
const joiner = indentStr
? ',\n' + indentStr.repeat(depth + 1)
: ',';
if (Array.isArray(value)) {
// Empty container stays compact even with indent set — matches
// native JSON.stringify behaviour.
if (value.length === 0) return '[]';
const items = value.map((item, i) => {
const s = serialize(item, String(i), ancestors, depth + 1);
// Array-item undefined -> "null" (different from objects!).
return s === undefined ? 'null' : s;
});
return `[${sep}${items.join(joiner)}${closeSep}]`;
}
// Plain object branch. Object.keys -> own enumerable string keys
// in insertion order. Symbol keys and inherited props skipped.
const keys = Object.keys(value);
// Apply the array-form replacer here — at every object, not just
// root. The membership check is what makes it "filter at every
// level"; arrays bypass this branch entirely.
const filtered = keyFilter
? keys.filter((k) => keyFilter.has(k))
: keys;
const entries = filtered
.map((k) => {
const s = serialize(value[k], k, ancestors, depth + 1);
// Object-value undefined -> drop the key entirely.
return s === undefined
? null
: `"${escapeString(k)}"${colon}${s}`;
})
.filter((e) => e !== null);
if (entries.length === 0) return '{}';
return `{${sep}${entries.join(joiner)}${closeSep}}`;
} finally {
// The try/finally pattern matters. If a deeper serialize() throws
// (e.g. on a cycle), we still un-pop `value` from ancestors so
// any future call referencing this object via a non-overlapping
// path can succeed. Without finally, a sibling-tree serialization
// would falsely see `value` as an ancestor.
ancestors.delete(value);
}
}
}
return undefined;
}
return serialize(value, '', new Set(), 0);
}
function escapeString(str) {
// Same escape logic as the medium implementation. Backslash MUST be
// doubled first — otherwise the backslashes we add for \n, \t, etc.
// would themselves get doubled on a second pass.
let out = '';
for (const ch of str) {
const code = ch.charCodeAt(0);
if (ch === '\\') out += '\\\\';
else if (ch === '"') out += '\\"';
else if (ch === '\n') out += '\\n';
else if (ch === '\t') out += '\\t';
else if (ch === '\r') out += '\\r';
else if (ch === '\b') out += '\\b';
else if (ch === '\f') out += '\\f';
else if (code < 0x20) {
// Any other U+0000..U+001F control char — six-char \uXXXX form.
out += '\\u' + code.toString(16).padStart(4, '0');
} else {
out += ch;
}
}
return out;
}
module.exports = { myJsonStringifyII };
A handful of decisions in that block deserve a one-line "why" each.
Normalize indent once at the top. Every recursive call uses the indent unit several times per object. Normalizing once saves O(depth × keys) recomputations.
Split the replacer into keyFilter (a Set) and replacerFn. The recursion branches on each independently, and Set.has is O(1). One up-front type-check beats one per call. Array.isArray is also checked BEFORE the function check, because arrays are objects and a naive typeof === 'object' would swallow the array form.
The inner helper takes (value, key, ancestors, depth). The medium version had only value. The four-parameter shape is what lets the three new hooks compose: key for the replacer, ancestors for the cycle guard, depth for the indent formatter. Each parameter is consumed by exactly one hook.
toJSON runs before the replacer. Spec ordering. Date#toJSON returns an ISO string; a replacer like (k, v) => typeof v === 'string' ? v.toUpperCase() : v expects strings. Reverse the order and dates no longer get touched.
replacerFn.call({ [key]: value }, key, value) instead of replacerFn(key, value). The spec sets this inside the replacer to a synthetic holder. Replacers that read this.someOtherKey silently break without the .call form.
ancestors.add + try/finally-ancestors.delete. Heart of path-based cycle detection. Adding alone is the seen-set bug from naive attempt 2; the delete is what makes the set track current path instead of ever-visited. The finally ensures the delete fires even if a deeper call throws — without it, a sibling-tree call later would falsely see this object as an ancestor.
Empty {} and [] stay compact. Native JSON.stringify({}, null, 2) returns "{}", not "{\n}". The length === 0 guards short-circuit before the separators get spliced in.
Array items pass String(i) as the key. The spec says the key the replacer sees is the stringified index. Passing the numeric i breaks replacers that check if (key === '0'). Array-item undefined still becomes 'null'; object-value undefined still drops the key — both medium-version rules carry over unchanged.
Two concrete traces — one for the replacer + indent combination, one for the cycle vs DAG distinction.
myJsonStringifyII(
{ a: 1, b: 2 },
(k, v) => (typeof v === 'number' ? v * 10 : v),
2,
);
indent = 2, so indentStr = ' ' (two spaces). replacer is a function, so replacerFn is set, keyFilter stays null. Call serialize({ a: 1, b: 2 }, '', new Set(), 0).toJSON on a plain object. Replacer fires: replacerFn('', { a: 1, b: 2 }). typeof v === 'number' is false for the object, so it returns the object unchanged. Not null. typeof value === 'object'. Not yet in ancestors. Add the root to ancestors.depth = 0, so sep = '\n' + ' '.repeat(1) = '\n ', closeSep = '\n' + ' '.repeat(0) = '\n', joiner = ',\n ', colon = ': '.Object.keys returns ['a', 'b']. No keyFilter, so filtered = ['a', 'b']. Map over them.'a'. Recurse: serialize(1, 'a', ancestors, 1). No toJSON on a number. Replacer fires: replacerFn('a', 1). typeof 1 === 'number', so it returns 10. Type dispatch — number branch, finite, returns '10'. Entry becomes "a": 10.'b'. Recurse: serialize(2, 'b', ancestors, 1). Same path — replacer doubles 2 to 20. Returns '20'. Entry becomes "b": 20.entries = ['"a": 10', '"b": 20'], joined by ,\n → '"a": 10,\n "b": 20'. Wrapped: '{\n "a": 10,\n "b": 20\n}'. Ancestors.delete(root) in the finally.'{\n "a": 10,\n "b": 20\n}' — the pretty-printed, doubled output.The flow is: indent setup happens once; the replacer fires before every dispatch, including the root with the empty-string key; the depth context is what makes the nested padding correct.
xconst x = { n: 1 };
const obj = { a: x, b: x };
myJsonStringifyII(obj);
indentStr = '' (no indent), both replacer hooks null. serialize(obj, '', new Set(), 0).ancestors. Add obj. ancestors = {obj}.'a'. Recurse: serialize(x, 'a', ancestors, 1). Object. ancestors.has(x)? No (we've only added obj). Add x. ancestors = {obj, x}. Object.keys → ['n']. Recurse on 1 → returns '1'. Object branch returns '{"n":1}'. finally runs: ancestors.delete(x). ancestors = {obj}.'b'. Recurse: serialize(x, 'b', ancestors, 1). Object. ancestors.has(x)? No — we removed it in step 3's finally. Add x. Same traversal as step 3, returns '{"n":1}'. finally removes x again.['"a":{"n":1}', '"b":{"n":1}']. Wrapped: '{"a":{"n":1},"b":{"n":1}}'. ancestors.delete(obj). Done.Now contrast with const a = {}; a.self = a; myJsonStringifyII(a):
a to ancestors. Object.keys → ['self'].'self'. Recurse: serialize(a, 'self', ancestors, 1). Object. ancestors.has(a)? YES — we added it one frame up and have not yet removed it. Throw TypeError.finally runs (removing a from ancestors, which doesn't matter now) and the error bubbles to the user.The single difference is whether the recursive call sees its argument already in ancestors. Path-based detection means "ancestor on the path I'm currently descending"; the try/finally-delete is what makes that semantic correct.
myJsonStringifyII({a:1}, [k => k]) discards both keys because String(k => k) doesn't match 'a'.toJSON runs BEFORE the replacer. Spec ordering. Concrete: a replacer that uppercases strings — (k, v) => typeof v === 'string' ? v.toUpperCase() : v — applied to a Date will see the ISO string "2026-06-04T..." and uppercase it. Swap the order, the replacer sees a raw Date object and silently passes it through unchanged.ancestors.delete on ascent is NOT optional. Drop it and DAGs falsely throw. Concrete: const x = {}; myJsonStringifyII([x, x]) — without the delete, the second visit to x finds it still in the ancestors set and throws "circular structure" even though there's no cycle.try/finally around the recursive descent matters. If a deeper serialize throws (e.g. on a cycle several levels down), an ancestors.add without finally leaves the current value permanently stuck in the set. Any later sibling-tree call referencing that same object then falsely reports a cycle.this holding the value. The spec says the function is called with this set to an object whose [key] is the value being inspected. Most replacers ignore this, but the ones that read it break if you call the function bare — use replacerFn.call({ [key]: value }, key, value), not replacerFn(key, value).myJsonStringifyII({}, null, '0123456789X') uses '0123456789' as the unit and discards the trailing X without warning. The spec calls this "implementation-defined truncation"; native JSON.stringify does the same thing.{} and [] stay compact even with indent set. myJsonStringifyII({}, null, 2) returns '{}', not '{\n}'. If you emit the newlines unconditionally, your output diverges from native JSON.stringify and from every JSON formatter on the web.serialize into a generator that yields string chunks, consumed via for await into a file write stream. The recursion is unchanged; the return type flips from string to AsyncIterable<string>. The cycle guard still needs try/finally semantics, and generator exceptions need care.reviver for the parse direction. JSON.parse(text, reviver) is the symmetric counterpart: a function called on every parsed key/value pair, whose return value replaces the value (or deletes the key on undefined). This is how you hydrate Date strings back into Date instances. Implementing it parses first, then walks bottom-up — different recursion shape from serialization.Symbol.toPrimitive vs toJSON. toJSON is JSON-specific. Symbol.toPrimitive is the general-purpose coercion hook for +, ==, template literals. If a class defines both, JSON.stringify uses toJSON; template interpolation uses toPrimitive. Pick whichever matches the coercion you want to customize.{ '10': 'a', '2': 'b' } serializes as '{"2":"b","10":"a"}'. For content-addressable hashing or deterministic diffs, wrap the object branch with keys.sort() before iterating. The "canonical JSON" libraries fix this and number-formatting at the same hook point.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
The medium myJsonStringify handles the type-dispatch core — primitives, arrays, objects, the undefined-triple rule. This version layers on the three spec features production code depends on: a replacer (filter or transform), an indent (pretty-printing), and circular-reference detection that throws like native JSON.stringify. Defer to the medium version for the basics; this question is about layering the new features cleanly on top.
function myJsonStringifyII(
value: unknown,
replacer?: ((key: string, value: unknown) => unknown) | string[] | null,
indent?: number | string | null,
): string | undefined;
// Compact mode (same behaviour as the medium version).
myJsonStringifyII({ a: 1, b: 2 });
// '{"a":1,"b":2}'
// Replacer as an allow-list array — only listed keys survive.
myJsonStringifyII({ a: 1, b: 2, c: 3 }, ['a', 'c']);
// '{"a":1,"c":3}'
// Replacer as a function — runs for every key/value pair, return value
// is what gets serialized. Return undefined to drop a key.
myJsonStringifyII({ a: 1, b: 2 }, (key, value) =>
typeof value === 'number' ? value * 10 : value,
);
// '{"a":10,"b":20}'
// Numeric indent — newlines between entries, `indent` spaces per level.
myJsonStringifyII({ a: 1, b: [2, 3] }, null, 2);
// '{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}'
// String indent — used verbatim (clipped to 10 chars).
myJsonStringifyII({ a: 1 }, null, '\t');
// '{\n\t"a": 1\n}'
// Circular reference — throws, just like native JSON.stringify.
const a = {};
a.self = a;
myJsonStringifyII(a);
// TypeError: Converting circular structure to JSON
replacer is an array, treat it as a string allow-list applied at every object (not just the root). If it's a function, call it with (key, value) for every key/value pair, including the root keyed as the empty string "". Anything else (including null/undefined) means no filtering.value. Returning undefined drops the key from an object (or becomes "null" inside an array, mirroring the medium version's array-item rule).n in [1, 10] becomes n spaces; numbers outside that range are clipped (above 10) or ignored (zero, negative, NaN). A string is used verbatim but clipped to its first 10 characters. Anything else means compact mode.{} and [] stay compact (no inner newline) even with indent set.toJSON hook — if a value's own .toJSON is a function, call it with the key and serialize its return value instead. This runs BEFORE the replacer (matches spec ordering).BigInt (would normally throw), wrapped primitive objects (new Number(1)), and the spec's "well-known symbol" branches.You'll keep the medium myJsonStringify type-dispatch core and bolt three orthogonal hooks onto it — a per-pair replacer, a depth-aware indent formatter, and a path-based cycle guard — without rewriting any of the existing branches.
The medium version handles type dispatch. It's enough for "convert this data to JSON," but not for what production code actually uses JSON.stringify for. You want to redact a password before logging — that's a replacer. You want a config dump humans can read — that's indent. You want to fail fast on a self-referencing object instead of blowing the stack — that's cycle detection. Each is a real engineering need, each is in the spec, and each composes cleanly on top of the type-dispatch core if wired in the right place. The trap is that the three look entangled at first; the lesson of this question is that they aren't.
Three hooks. Each one slots into the recursive walk at a specific moment and is independent of the other two.
One — the replacer transforms what gets serialized. Before any type dispatch runs, the replacer (if any) sees the current (key, value) pair and either returns a different value or returns undefined to drop it. Two flavours: as a function it's called for every pair; as an array it's a string allow-list applied at every plain object. Same parameter slot, two completely different calling conventions.
Two — the indent formatter decides spacing. Compact mode emits {"a":1,"b":2}; pretty mode emits {\n "a": 1,\n "b": 2\n}. Width is indent × depth, so every level adds one unit of padding. Empty {} and [] stay compact even with indent set — a small spec wrinkle.
Three — the cycle guard tracks ancestors on the current descent path. Entering an object adds it to an ancestors set; leaving removes it. If we try to enter an object already in the set, we've followed a back-edge — throw. Ancestors are the path I'm on now, not every object I've ever seen. A DAG (same object under two sibling branches) is fine; only a true cycle throws.
The piece beginners get wrong most often is the spec ordering inside a single call: toJSON runs first, then the replacer, then the type dispatch. Swap the first two and most date-aware replacers break — they're written expecting the ISO string the Date#toJSON produces, not the raw Date instance.
Two naive approaches show up reliably in this question. Both are wrong, and they're wrong in different ways.
The reflex is to crack open the medium myJsonStringify and try to handle the new arguments at every recursive call site:
function myJsonStringifyIIBad(value, replacer, indent) {
if (value === null) return 'null';
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'null';
// ...primitives copy/pasted from medium...
if (Array.isArray(value)) {
const items = value.map((item, i) => {
// Try to call replacer here. But what's the key? The index? A string?
// And how do I know my depth for indent? It isn't passed in!
const v = typeof replacer === 'function' ? replacer(String(i), item) : item;
return myJsonStringifyIIBad(v, replacer, indent);
});
return `[${items.join(',')}]`;
}
// ...same problem at the object branch...
}
The replacer needs the key the value was found under — which the medium function never carries. Adding key everywhere is workable, but depth is the deeper problem: indent grows linearly with nesting and the medium signature has no depth context either. You can patch each call site one by one, but the signature keeps growing and the branches tangle. Worse, the replacer-array form must filter at every object, adding a third branch. By the end you've doubled the line count and still don't have cycle detection. The right fix is one inner helper with the new parameters — key, ancestors, depth — wrapped by an outer function that initializes them.
seen Set for cycle detectionThe other reflex is to detect cycles with a global "have I seen this object?" set:
function withSeenSet(value) {
const seen = new Set();
function go(v) {
if (v && typeof v === 'object') {
if (seen.has(v)) throw new TypeError('Converting circular structure to JSON');
seen.add(v); // ← never removed
}
// ...dispatch...
}
return go(value);
}
Concrete failure:
const x = { n: 1 };
withSeenSet({ a: x, b: x });
// throws — but { a: x, b: x } is a DAG, not a cycle. Native JSON.stringify
// happily produces '{"a":{"n":1},"b":{"n":1}}' for this input.
"Have I seen this?" is a different question from "is this my ancestor right now?". When we finish serializing a and pop back up to the parent, x is no longer on our path — visiting it again under sibling b is fine. The seen-set approach forgets to pop and can't distinguish a back-edge (cycle) from a cross-edge (shared sub-structure). The fix is to track ancestors specifically: add on descent, remove on ascent, with try/finally to guarantee the removal even when a deeper call throws.
function myJsonStringifyII(value, replacer = null, indent = 0) {
// Normalize the indent ONCE up front. The recursion uses this many times
// per level, so spending a few cycles here saves recomputation later.
// Number path: a positive finite number, clipped to 10, becomes that many
// spaces. String path: used verbatim, but clipped to the first 10 chars.
// Anything else (null, undefined, 0, NaN, negative, oversized object) ->
// empty string, which is the "compact mode" flag the recursion checks.
let indentStr = '';
if (typeof indent === 'number' && Number.isFinite(indent) && indent > 0) {
indentStr = ' '.repeat(Math.min(10, Math.floor(indent)));
} else if (typeof indent === 'string') {
indentStr = indent.slice(0, 10);
}
// Split the replacer into two narrow hooks the recursion can branch on
// without re-checking the type each call. A Set gives O(1) `has` lookups
// for the array form; the function form is just a reference we can call.
const keyFilter = Array.isArray(replacer)
? new Set(replacer.map(String))
: null;
const replacerFn = typeof replacer === 'function' ? replacer : null;
function serialize(value, key, ancestors, depth) {
// toJSON hook fires BEFORE the replacer (spec ordering). If you swap
// these, replacer(k, dateValue) sees the Date object instead of the
// already-converted ISO string and most real-world replacers break.
if (
value &&
typeof value === 'object' &&
typeof value.toJSON === 'function'
) {
value = value.toJSON(key);
}
// Replacer-function hook: gets the final say on what value flows into
// the type dispatch. Returning undefined here drops the slot (the
// container branches below interpret undefined as "omit" or "null").
if (replacerFn) {
value = replacerFn.call({ [key]: value }, key, value);
}
if (value === null) return 'null';
switch (typeof value) {
case 'string':
return `"${escapeString(value)}"`;
case 'number':
// Spec: NaN and +/-Infinity have no JSON form — emit "null".
return Number.isFinite(value) ? String(value) : 'null';
case 'boolean':
return String(value);
case 'undefined':
case 'function':
case 'symbol':
// The "absent value" return — each container interprets this:
// object value -> key dropped; array item -> literal "null"; top
// level -> undefined propagates to the caller.
return undefined;
case 'object': {
// PATH-based cycle detection. `ancestors` holds objects on the
// current descent path, NOT every object ever seen. The DAG case
// (same object reachable via two non-overlapping branches) must
// still serialize — only true cycles throw.
if (ancestors.has(value)) {
throw new TypeError('Converting circular structure to JSON');
}
ancestors.add(value);
try {
// Precompute the per-level separators. `sep` opens after `{`/`[`,
// `closeSep` un-indents before the matching `}`/`]`, `joiner` sits
// between sibling entries. In compact mode all three collapse.
const sep = indentStr ? '\n' + indentStr.repeat(depth + 1) : '';
const closeSep = indentStr ? '\n' + indentStr.repeat(depth) : '';
const colon = indentStr ? ': ' : ':';
const joiner = indentStr
? ',\n' + indentStr.repeat(depth + 1)
: ',';
if (Array.isArray(value)) {
// Empty container stays compact even with indent set — matches
// native JSON.stringify behaviour.
if (value.length === 0) return '[]';
const items = value.map((item, i) => {
const s = serialize(item, String(i), ancestors, depth + 1);
// Array-item undefined -> "null" (different from objects!).
return s === undefined ? 'null' : s;
});
return `[${sep}${items.join(joiner)}${closeSep}]`;
}
// Plain object branch. Object.keys -> own enumerable string keys
// in insertion order. Symbol keys and inherited props skipped.
const keys = Object.keys(value);
// Apply the array-form replacer here — at every object, not just
// root. The membership check is what makes it "filter at every
// level"; arrays bypass this branch entirely.
const filtered = keyFilter
? keys.filter((k) => keyFilter.has(k))
: keys;
const entries = filtered
.map((k) => {
const s = serialize(value[k], k, ancestors, depth + 1);
// Object-value undefined -> drop the key entirely.
return s === undefined
? null
: `"${escapeString(k)}"${colon}${s}`;
})
.filter((e) => e !== null);
if (entries.length === 0) return '{}';
return `{${sep}${entries.join(joiner)}${closeSep}}`;
} finally {
// The try/finally pattern matters. If a deeper serialize() throws
// (e.g. on a cycle), we still un-pop `value` from ancestors so
// any future call referencing this object via a non-overlapping
// path can succeed. Without finally, a sibling-tree serialization
// would falsely see `value` as an ancestor.
ancestors.delete(value);
}
}
}
return undefined;
}
return serialize(value, '', new Set(), 0);
}
function escapeString(str) {
// Same escape logic as the medium implementation. Backslash MUST be
// doubled first — otherwise the backslashes we add for \n, \t, etc.
// would themselves get doubled on a second pass.
let out = '';
for (const ch of str) {
const code = ch.charCodeAt(0);
if (ch === '\\') out += '\\\\';
else if (ch === '"') out += '\\"';
else if (ch === '\n') out += '\\n';
else if (ch === '\t') out += '\\t';
else if (ch === '\r') out += '\\r';
else if (ch === '\b') out += '\\b';
else if (ch === '\f') out += '\\f';
else if (code < 0x20) {
// Any other U+0000..U+001F control char — six-char \uXXXX form.
out += '\\u' + code.toString(16).padStart(4, '0');
} else {
out += ch;
}
}
return out;
}
module.exports = { myJsonStringifyII };
A handful of decisions in that block deserve a one-line "why" each.
Normalize indent once at the top. Every recursive call uses the indent unit several times per object. Normalizing once saves O(depth × keys) recomputations.
Split the replacer into keyFilter (a Set) and replacerFn. The recursion branches on each independently, and Set.has is O(1). One up-front type-check beats one per call. Array.isArray is also checked BEFORE the function check, because arrays are objects and a naive typeof === 'object' would swallow the array form.
The inner helper takes (value, key, ancestors, depth). The medium version had only value. The four-parameter shape is what lets the three new hooks compose: key for the replacer, ancestors for the cycle guard, depth for the indent formatter. Each parameter is consumed by exactly one hook.
toJSON runs before the replacer. Spec ordering. Date#toJSON returns an ISO string; a replacer like (k, v) => typeof v === 'string' ? v.toUpperCase() : v expects strings. Reverse the order and dates no longer get touched.
replacerFn.call({ [key]: value }, key, value) instead of replacerFn(key, value). The spec sets this inside the replacer to a synthetic holder. Replacers that read this.someOtherKey silently break without the .call form.
ancestors.add + try/finally-ancestors.delete. Heart of path-based cycle detection. Adding alone is the seen-set bug from naive attempt 2; the delete is what makes the set track current path instead of ever-visited. The finally ensures the delete fires even if a deeper call throws — without it, a sibling-tree call later would falsely see this object as an ancestor.
Empty {} and [] stay compact. Native JSON.stringify({}, null, 2) returns "{}", not "{\n}". The length === 0 guards short-circuit before the separators get spliced in.
Array items pass String(i) as the key. The spec says the key the replacer sees is the stringified index. Passing the numeric i breaks replacers that check if (key === '0'). Array-item undefined still becomes 'null'; object-value undefined still drops the key — both medium-version rules carry over unchanged.
Two concrete traces — one for the replacer + indent combination, one for the cycle vs DAG distinction.
myJsonStringifyII(
{ a: 1, b: 2 },
(k, v) => (typeof v === 'number' ? v * 10 : v),
2,
);
indent = 2, so indentStr = ' ' (two spaces). replacer is a function, so replacerFn is set, keyFilter stays null. Call serialize({ a: 1, b: 2 }, '', new Set(), 0).toJSON on a plain object. Replacer fires: replacerFn('', { a: 1, b: 2 }). typeof v === 'number' is false for the object, so it returns the object unchanged. Not null. typeof value === 'object'. Not yet in ancestors. Add the root to ancestors.depth = 0, so sep = '\n' + ' '.repeat(1) = '\n ', closeSep = '\n' + ' '.repeat(0) = '\n', joiner = ',\n ', colon = ': '.Object.keys returns ['a', 'b']. No keyFilter, so filtered = ['a', 'b']. Map over them.'a'. Recurse: serialize(1, 'a', ancestors, 1). No toJSON on a number. Replacer fires: replacerFn('a', 1). typeof 1 === 'number', so it returns 10. Type dispatch — number branch, finite, returns '10'. Entry becomes "a": 10.'b'. Recurse: serialize(2, 'b', ancestors, 1). Same path — replacer doubles 2 to 20. Returns '20'. Entry becomes "b": 20.entries = ['"a": 10', '"b": 20'], joined by ,\n → '"a": 10,\n "b": 20'. Wrapped: '{\n "a": 10,\n "b": 20\n}'. Ancestors.delete(root) in the finally.'{\n "a": 10,\n "b": 20\n}' — the pretty-printed, doubled output.The flow is: indent setup happens once; the replacer fires before every dispatch, including the root with the empty-string key; the depth context is what makes the nested padding correct.
xconst x = { n: 1 };
const obj = { a: x, b: x };
myJsonStringifyII(obj);
indentStr = '' (no indent), both replacer hooks null. serialize(obj, '', new Set(), 0).ancestors. Add obj. ancestors = {obj}.'a'. Recurse: serialize(x, 'a', ancestors, 1). Object. ancestors.has(x)? No (we've only added obj). Add x. ancestors = {obj, x}. Object.keys → ['n']. Recurse on 1 → returns '1'. Object branch returns '{"n":1}'. finally runs: ancestors.delete(x). ancestors = {obj}.'b'. Recurse: serialize(x, 'b', ancestors, 1). Object. ancestors.has(x)? No — we removed it in step 3's finally. Add x. Same traversal as step 3, returns '{"n":1}'. finally removes x again.['"a":{"n":1}', '"b":{"n":1}']. Wrapped: '{"a":{"n":1},"b":{"n":1}}'. ancestors.delete(obj). Done.Now contrast with const a = {}; a.self = a; myJsonStringifyII(a):
a to ancestors. Object.keys → ['self'].'self'. Recurse: serialize(a, 'self', ancestors, 1). Object. ancestors.has(a)? YES — we added it one frame up and have not yet removed it. Throw TypeError.finally runs (removing a from ancestors, which doesn't matter now) and the error bubbles to the user.The single difference is whether the recursive call sees its argument already in ancestors. Path-based detection means "ancestor on the path I'm currently descending"; the try/finally-delete is what makes that semantic correct.
myJsonStringifyII({a:1}, [k => k]) discards both keys because String(k => k) doesn't match 'a'.toJSON runs BEFORE the replacer. Spec ordering. Concrete: a replacer that uppercases strings — (k, v) => typeof v === 'string' ? v.toUpperCase() : v — applied to a Date will see the ISO string "2026-06-04T..." and uppercase it. Swap the order, the replacer sees a raw Date object and silently passes it through unchanged.ancestors.delete on ascent is NOT optional. Drop it and DAGs falsely throw. Concrete: const x = {}; myJsonStringifyII([x, x]) — without the delete, the second visit to x finds it still in the ancestors set and throws "circular structure" even though there's no cycle.try/finally around the recursive descent matters. If a deeper serialize throws (e.g. on a cycle several levels down), an ancestors.add without finally leaves the current value permanently stuck in the set. Any later sibling-tree call referencing that same object then falsely reports a cycle.this holding the value. The spec says the function is called with this set to an object whose [key] is the value being inspected. Most replacers ignore this, but the ones that read it break if you call the function bare — use replacerFn.call({ [key]: value }, key, value), not replacerFn(key, value).myJsonStringifyII({}, null, '0123456789X') uses '0123456789' as the unit and discards the trailing X without warning. The spec calls this "implementation-defined truncation"; native JSON.stringify does the same thing.{} and [] stay compact even with indent set. myJsonStringifyII({}, null, 2) returns '{}', not '{\n}'. If you emit the newlines unconditionally, your output diverges from native JSON.stringify and from every JSON formatter on the web.serialize into a generator that yields string chunks, consumed via for await into a file write stream. The recursion is unchanged; the return type flips from string to AsyncIterable<string>. The cycle guard still needs try/finally semantics, and generator exceptions need care.reviver for the parse direction. JSON.parse(text, reviver) is the symmetric counterpart: a function called on every parsed key/value pair, whose return value replaces the value (or deletes the key on undefined). This is how you hydrate Date strings back into Date instances. Implementing it parses first, then walks bottom-up — different recursion shape from serialization.Symbol.toPrimitive vs toJSON. toJSON is JSON-specific. Symbol.toPrimitive is the general-purpose coercion hook for +, ==, template literals. If a class defines both, JSON.stringify uses toJSON; template interpolation uses toPrimitive. Pick whichever matches the coercion you want to customize.{ '10': 'a', '2': 'b' } serializes as '{"2":"b","10":"a"}'. For content-addressable hashing or deterministic diffs, wrap the object branch with keys.sort() before iterating. The "canonical JSON" libraries fix this and number-formatting at the same hook point.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.