Given const o = { m: new Map([['a', 1]]), s: new Set([1, 2]), n: NaN, i: Infinity }, what does JSON.stringify(o) produce — and what changes if you add a BigInt property?
answer
- only own enumerable properties are visited
- collection contents live in internal slots
- the number grammar is finite decimals only
- one of these fails loudly, the rest quietly
- convert on the way out, restore on the way in
basics
~20 sYou get '{"m":{},"s":{},"n":null,"i":null}'. Map and Set serialize as empty objects because their contents are internal rather than own properties, NaN and Infinity have no JSON literal so they become null, and adding a BigInt makes the call throw a TypeError.
solid answer
~40 sThe result is `'{"m":{},"s":{},"n":null,"i":null}'`. `Map` and `Set` keep their entries in internal slots, and serialization only visits own enumerable properties — of which they have none — so both collapse to `{}` with no warning at all. `NaN` and `Infinity` are numbers to JavaScript but the JSON grammar has no literal for either, so both are written as `null`; `-0` likewise serializes as `0`. Adding a `BigInt` anywhere in the value changes the failure mode entirely: instead of losing data quietly the call throws a `TypeError`, because the format has no arbitrary-precision integer and the language refuses to guess whether you wanted a number or a string. The fix for all of these is the same: convert explicitly in a replacer, and restore in a matching reviver. BigInt is ES2020.
code
javascript · 12 linesconst o = { m: new Map([['a', 1]]), s: new Set([1, 2]), n: NaN, i: Infinity };
console.log(JSON.stringify(o));
// {"m":{},"s":{},"n":null,"i":null}
console.log(JSON.stringify({ z: -0 })); // {"z":0}
console.log(JSON.stringify(new Uint8Array([1, 2]))); // {"0":1,"1":2}
try {
JSON.stringify({ v: 10n });
} catch (err) {
console.log(err.constructor.name); // TypeError
}go deeper
Remember the headline results: Map and Set come out as empty objects, NaN and Infinity come out as null, and a BigInt makes the call throw. Recognise them when you see them in a payload.
Explain the mechanism — only own enumerable properties are visited, and collection contents live in internal slots — and why JSON's finite-decimal number grammar forces null for the non-finite values.
Argue about failure modes: silent emptiness is worse than a throw because it ships. Show the tagged replacer/reviver codec and say why patching built-in prototypes is the wrong fix.
Own the interchange decision — whether these types belong on the wire at all, whether the codec is a shared contract both sides validate against, and when the answer is to pick a format that carries them natively.
## One rule explains most of it Serialization visits an object's **own enumerable String-keyed properties**. Almost every surprise in this area follows from that sentence plus the fact that JSON's number grammar is finite decimal notation. ## Map and Set collapse to {} ```js JSON.stringify(new Map([['a', 1]])); // '{}' JSON.stringify(new Set([1, 2])); // '{}' ``` The entries of a `Map` and the members of a `Set` live in internal slots that only their own methods can reach. They are not properties, so there is nothing for the algorithm to enumerate. This is the most dangerous item in the list because the failure is completely silent: the code runs, the payload is well-formed, and the data is gone. A cache keyed by `Map` that round-trips through storage comes back empty. The same reasoning explains a related case in the other direction: a typed array *does* have own indexed properties, so `JSON.stringify(new Uint8Array([1, 2]))` gives `'{"0":1,"1":2}'` — an object, not an array, and considerably larger than the bytes it represents. ## NaN, Infinity, and negative zero ```js JSON.stringify({ n: NaN, i: Infinity, j: -Infinity, z: -0 }); // '{"n":null,"i":null,"j":null,"z":0}' ``` JSON's number production is a finite decimal literal with an optional exponent. There is no token for a not-a-number value and none for either infinity, so the algorithm substitutes `null` — the only value in the grammar that means "nothing usable here". Note the asymmetry with `-0`: it *is* finite, so it serializes as the number `0`, and its sign is lost rather than nulled. (`JSON.parse('-0')` does produce negative zero, so the loss happens on the way out, not the way back.) The practical consequence is that a numeric field arriving as `null` is ambiguous. It might mean "absent", or it might mean a computation upstream divided by zero and nobody noticed. ## BigInt throws instead ```js JSON.stringify({ v: 10n }); // TypeError: Do not know how to serialize a BigInt ``` This is the one case in the family that fails loudly, and deliberately so. Writing a big integer as a JSON number would round-trip through the double-precision parser and silently lose the precision that was the entire reason for using it; writing it as a string would silently change the type. Rather than pick, the specification throws and makes you decide. BigInt was added in ES2020, so the throwing behaviour dates from then. The decision is yours to encode: ```js JSON.stringify({ v: 10n }, (key, value) => typeof value === 'bigint' ? value.toString() : value ); // '{"v":"10"}' ``` That is lossless in text, but the consumer now receives a string and must know to convert it back. ## The general fix: an explicit codec pair Every loss above is fixable, and the fix is always the same shape — convert in a replacer on the way out, restore in a reviver on the way in, using a tag the two sides agree on: ```js const replacer = (key, value) => { if (value instanceof Map) return { $type: 'map', v: [...value] }; if (value instanceof Set) return { $type: 'set', v: [...value] }; if (typeof value === 'bigint') return { $type: 'bigint', v: value.toString() }; return value; }; const reviver = (key, value) => { if (value && value.$type === 'map') return new Map(value.v); if (value && value.$type === 'set') return new Set(value.v); if (value && value.$type === 'bigint') return BigInt(value.v); return value; }; const text = JSON.stringify({ m: new Map([['a', 1]]), big: 9007199254740993n }, replacer); JSON.parse(text, reviver).m.get('a'); // 1 ``` The replacer's top-down traversal means the substitute object is itself walked, so nested collections convert too. The reviver's bottom-up traversal means the array under `v` is already finished when the constructor runs. The two orders are complementary by design. A tempting shortcut is to attach a serialization hook to `Map.prototype` so it "just works" everywhere. Resist it: patching a built-in prototype changes behaviour for every library in the process, including ones that rely on the default, and the receiving side still has no idea how to rebuild the value. ## What to take away The format is a text interchange format with six value shapes, not a snapshot of a JavaScript value. Anything richer than those six shapes crosses the boundary only if you encode it deliberately — and the failure mode differs by type: silent emptiness for the collections, `null` for the non-finite numbers, a thrown `TypeError` for arbitrary-precision integers.
- Why does a BigInt throw when NaN merely becomes null?Because both plausible encodings for a BigInt lose something the caller cares about. Writing it as a JSON number would round-trip through double-precision parsing and destroy the precision that motivated using BigInt; writing it as a string would silently change the type on the wire. NaN has no such dilemma — it is unrepresentable in any encoding, so null is the honest substitute. Throwing forces you to make the choice explicitly.
- Why not just add a serialization hook to Map.prototype so maps serialize everywhere?Because patching a built-in prototype changes behaviour process-wide, including for libraries that depend on the default and for future code that has no idea the patch exists. It also only solves half the problem: the receiver still cannot rebuild a Map from whatever shape you chose. A replacer/reviver pair keeps the convention local and symmetric.
- What does JSON.stringify do with a typed array such as Uint8Array?It emits a JSON *object* with numeric string keys — `new Uint8Array([1, 2])` gives `'{"0":1,"1":2}'` — because a typed array exposes own indexed properties that the algorithm can enumerate. That is both the wrong shape for the consumer and far larger than the underlying bytes, so binary data is normally encoded explicitly instead.
saying these in an interview costs you the question
- Expects a Map to serialize as an object of its entries
- Thinks NaN appears in the output as NaN
- Assumes BigInt silently becomes a number or a string
- Believes an empty {} in the output means the Map was empty
- Suggests patching built-in prototypes so collections just work