You reach for JSON.stringify a hundred times a week. You hand it a POST body. You hand it a Redux devtools payload. You feed it a config file, a browser-cache entry, a log line. And if you're honest, you've never had to write one.
This chapter builds it from zero. Over the next seven screens you'll hit the clauses the real spec already spells out — one wall at a time — and by the end the function you wrote will match native JSON.stringify character-for-character. You'll also have watched it silently drop at least three categories of value that you probably didn't know it dropped.
stringify to match native JSON.stringify character-for-character. The first argument is the value to serialise — any JavaScript value, primitive or nested, dates and bigints included. The output is a JSON string — or undefined when the top-level value itself can't be represented, or a thrown TypeError on cycles and BigInt. Two more arguments show up later in the arc, once you've felt the walls that make them earn their place. Build it **without calling JSON.stringify itself** — the goal is to own every rule the spec picked out.stringify({ threshold: NaN, tags: ['x'] })'{"threshold":null,"tags":["x"]}'A dashboard writes filter state to localStorage every time the user flips a switch. The code is three lines: const saved = JSON.stringify(state); localStorage.setItem('filters', saved); and on reload, JSON.parse walks it back. Ship it. Two weeks later a bug report arrives: “I set the numeric threshold to 'none' using `NaN` as a sentinel, and every row now clamps below the comparison.” The code stored { threshold: NaN }. What came back on reload was { threshold: null }. Nothing threw. Nothing warned. The value quietly bent from a number into a null, and every downstream check — isNaN, typeof n === 'number', the numeric comparison — was looking at a different thing than what was written.
null·true / false·null·Drop a value in
// The press, branch by branch — 5 primitive cases.function castPrimitive(v) { if (v === null) return 'null'; if (typeof v === 'boolean') return v ? 'true' : 'false'; if (typeof v === 'number') return isFinite(v) ? String(v) : 'null'; if (typeof v === 'string') return quoteString(v); if (v === undefined) return undefined; // drop signal // (drop a value above to see which branch fires)}The press above is JSON.stringify at the primitive level. Five slots on the plate — null, boolean, number, string, undefined — each with a typesetting rule. Drop a value in; watch the correct letter cast, or watch the press substitute something else, or watch NOTHING come out at all. Feed all eight exemplars (the five clean cases plus the three surprises) and pay attention to where the press silences instead of emitting. The silencing is the whole footgun: a number goes in; a "null" comes out; no exception is raised; downstream code has no idea anything changed.
Why the silencing? JSON — the data interchange format — is older than JavaScript's numeric menagerie. When Douglas Crockford pinned the spec in 2001, JSON was defined as a SUBSET of JavaScript syntax: four scalar token types (null, booleans, numbers, strings), two structural kinds (arrays, objects), and nothing else. No undefined. No NaN. No Infinity. No functions. No symbols. Twenty-odd years later, JSON.stringify is still the boundary between the language you write and the wire-format that older subset admits — and when the two disagree, the spec chooses SILENT COERCION over safety. Non-finite numbers become the string "null". undefined produces no output. Functions vanish. The language rewrites itself at the boundary; you find out by reading the bug report.
Feed all 8 — you've tried 0 of 8. The silencing cases (NaN, Infinity) are in there; so is the drop signal (undefined).