skip to content

Replacer, Reviver, and JSON Edge Cases

JSON is not a faithful snapshot of a JS object: undefined, functions and symbols vanish, Map and Set flatten to {}, BigInt throws, and a circular reference is a hard TypeError. Interviewers love these because they separate people who have shipped serialization bugs from people who haven't.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

questions

6

What does JSON.stringify() do with undefined, function values and symbols, and how does the outcome differ between an object property, an array element, and the top-level argument?

level: juniorimportance: must knowfreq 75%

answer

  1. three values JSON cannot express
  2. position decides the outcome
  3. objects lose the key, arrays keep the slot
  4. top level returns no string at all
  5. symbol keys never serialize

basics

~20 s

JSON.stringify silently drops undefined, function and symbol object properties, turns those same values into null inside arrays, and returns the value undefined — not a string — when you pass one directly as the top-level argument.

solid answer

~40 s

JSON has no grammar for `undefined`, functions or symbols, so `JSON.stringify` treats all three as unserializable and then does one of three things depending on where the value sits. As an object property the key is dropped entirely: `JSON.stringify({a: 1, b: undefined})` gives `'{"a":1}'`. As an array element it becomes `null`, because dropping it would shift every later index: `JSON.stringify([1, undefined, 3])` gives `'[1,null,3]'`. As the top-level argument the call returns the value `undefined` rather than a string at all, so `typeof JSON.stringify(undefined)` is `'undefined'`. Symbol-*keyed* properties are skipped regardless of their value, as are non-enumerable and inherited properties. The practical consequence is that a round trip is not an identity function: `{a: undefined}` comes back as `{}`, so `'a' in obj` flips from true to false.

code

javascript · 5 lines
javascript
const obj = { a: 1, b: undefined, c() {}, d: Symbol('x') };
console.log(JSON.stringify(obj));                  // {"a":1}
console.log(JSON.stringify([1, undefined, 3]));    // [1,null,3]
console.log(JSON.stringify(undefined));            // undefined (the value)
console.log(JSON.stringify({ [Symbol('k')]: 1 })); // {}

go deeper

for a junior

Be able to state plainly that undefined, functions and symbols do not survive serialization: object keys vanish, array slots become null. Show the two-line example without hesitating.

for a middle

Explain why the array case differs — omitting an element would shift indices — and that only own, enumerable, String-keyed properties are visited, which is what excludes symbol keys, non-enumerable and inherited properties.

for a senior

Show where the silent drop causes production bugs: an absent field versus an explicit null in a PATCH body, cache entries holding the text "undefined", and equality checks built on stringify. Say how you normalise at the boundary.

for a principal

Own the contract question: decide project-wide whether absent and null mean different things on the wire, and enforce that with an explicit serialization layer rather than relying on whatever the default algorithm happens to preserve.

## The starting point: JSON's value grammar is small JSON can represent exactly six shapes: object, array, string, number, the booleans, and `null`. JavaScript has considerably more. Three everyday JavaScript values — `undefined`, functions, and symbols — have no JSON counterpart at all. The serialization algorithm produces an internal "nothing" result for them, and what you observe depends entirely on **where** in the value that nothing appeared. ## Object properties: the key disappears ```js JSON.stringify({ a: 1, b: undefined, c() {}, d: Symbol('x') }); // '{"a":1}' ``` There is no placeholder and no warning. Three of the four keys are simply not in the output. This is the case that causes real bugs, because the difference between "the field was absent" and "the field was explicitly null" is meaningful to most servers — a PATCH endpoint typically reads absent as *leave unchanged* and `null` as *clear this field*. ## Array elements: the slot becomes null ```js JSON.stringify([1, undefined, function () {}, Symbol('x')]); // '[1,null,null,null]' ``` Arrays are positional. Dropping index 1 would renumber everything after it and change the array's length, so the algorithm substitutes `null` instead. Sparse array holes behave the same way: `JSON.stringify([1, , 3])` yields `'[1,null,3]'`, so a hole and an explicit `null` are indistinguishable after a round trip. ## Top level: you get undefined, not a string ```js JSON.stringify(undefined); // undefined JSON.stringify(() => {}); // undefined typeof JSON.stringify(Symbol()); // 'undefined' ``` The return type of `JSON.stringify` is therefore `string | undefined`. Code that assumes a string will fail one step later: feeding that result straight back into `JSON.parse` coerces `undefined` to the string `"undefined"`, which is not valid JSON, so the failure surfaces at the parse call with a confusing message rather than at the stringify call where the real problem is. ## Symbol keys, non-enumerable keys, inherited keys Serialization enumerates a value's **own enumerable String-keyed** properties. Three exclusions follow directly from that phrase: ```js JSON.stringify({ [Symbol('k')]: 1 }); // '{}' const o = {}; Object.defineProperty(o, 'hidden', { value: 1, enumerable: false }); JSON.stringify(o); // '{}' JSON.stringify(Object.create({ inherited: 1 })); // '{}' ``` A symbol-keyed property is skipped even when its value is a perfectly serializable number. This is one reason symbols are a reasonable place to hang internal metadata you do not want crossing the wire. ## Why the asymmetry is not arbitrary All three positions are answering the same question — "what do I write when there is nothing to write?" — under different constraints. In an object, omitting a key is legal JSON and preserves the remaining structure. In an array, omission would corrupt indices, so a placeholder is required and `null` is the only neutral one available. At the top level there is no surrounding structure to place anything into, so the operation has no result at all. ## Where this shows up in real code - **Request bodies.** `JSON.stringify({ name, nickname })` where `nickname` is `undefined` sends `{"name":"..."}`. The server never sees the field. - **Class instances.** Methods live on the prototype and are neither own nor serializable, so an instance serializes to its data fields only, and the parsed result is a plain object with no behaviour. - **Structural comparison.** Comparing `JSON.stringify(a) === JSON.stringify(b)` treats `{x: undefined}` and `{}` as equal, and treats key insertion order as significant. Both are usually wrong for an equality check. - **Caches and logs.** A value that stringifies to `undefined` written into a cache stores the literal characters `undefined` if something concatenates it into a string. ## Making the loss explicit instead of silent The second argument to `JSON.stringify`, the replacer function, is invoked for every own enumerable key including ones whose value is `undefined`, so you can normalise them yourself: ```js JSON.stringify({ a: 1, b: undefined }, (key, value) => value === undefined ? null : value ); // '{"a":1,"b":null}' ``` That converts a silent omission into an explicit null, which is the right choice whenever the receiver distinguishes the two. The opposite discipline also works: strip `undefined` fields deliberately at the boundary so that what you send is exactly what you intended, rather than whatever happened to survive.

  • If a property is dropped, how would a caller ever notice something went missing?
    They usually notice downstream, not at the call. `Object.keys` shrinks, an `in` check flips to false, and a server treats the field as absent rather than cleared. If you need the distinction preserved, normalise at the boundary — map `undefined` to `null` in a replacer, or validate the payload shape before sending — rather than relying on the reader to spot an absent key.
  • Why does a class instance lose its methods when serialized?
    Methods declared in a class body live on the prototype, and serialization only visits own enumerable String-keyed properties. So instance fields survive and behaviour does not. Parsing the text back gives a plain object with the right data and no methods, which is why code that calls a method on a parsed value fails with "is not a function".
  • What is the return type of JSON.stringify, and why does that matter?
    `string | undefined`. It returns `undefined` whenever the top-level value is itself unserializable — `undefined`, a function, or a symbol. Code that annotates or assumes a string will pass `undefined` onward, and the failure typically appears at a later parse or concatenation with a misleading message. Check the result before using it when the input is not statically known.

It is like a form scanner that skips blank fields on a checklist, but on a numbered ballot must print a blank line so the numbering still lines up.

saying these in an interview costs you the question

  • Says undefined survives serialization as the literal undefined
  • Thinks object properties become null like array elements do
  • Assumes stringify always returns a string
  • Believes symbol-keyed properties serialize when the value is serializable
  • Claims stringify plus parse always reproduces the original object

context

open as a page

JSON.stringify accepts a second argument called the replacer. What are its two valid forms, and what does each one do to the output?

level: middleimportance: must knowfreq 58%

basics

~20 s

The replacer is JSON.stringify's second argument. An array of keys acts as an allowlist that also fixes the output key order; a function is called for every key and value pair and returns the value to serialize, or undefined to omit that property.

open as a page

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?

level: middleimportance: should knowfreq 52%

basics

~20 s

You 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.

open as a page

JSON.parse accepts a second argument called the reviver. What is it called with, in what order does it walk the parsed result, and what happens when it returns undefined?

level: middleimportance: should knowfreq 46%

basics

~20 s

The reviver is JSON.parse's second argument, called with each key and value from the innermost values outward. Its return value replaces the parsed one, and returning undefined deletes that property — so it can filter as well as transform.

open as a page

A service logs incoming requests and one day throws "TypeError: Converting circular structure to JSON". What exactly causes JSON.stringify to throw this, and how do you make such an object serializable?

level: seniorimportance: should knowfreq 48%

basics

~20 s

JSON.stringify walks the value as a tree, so when an object is reachable from itself along the current path it would recurse forever and the algorithm throws a TypeError instead. Fix it by serializing an explicit subset of fields, or with a replacer that tracks ancestors.

open as a page

What can and cannot go wrong when you call JSON.parse on a string that arrived from an untrusted source?

level: seniorimportance: should knowfreq 38%

basics

~20 s

JSON.parse never executes code — it produces only plain objects, arrays and primitives. The real hazards are silent ones: a "proto" key that becomes dangerous once the result is merged, duplicate keys where the last silently wins, lost precision on large integers, and unbounded input size.

open as a page