Why does JSON.stringify return undefined or drop fields?

Dark navy cover card with the title 'Why does JSON.stringify return undefined or drop fields?' in white and a green accent bar

JSON.stringify returns undefined when the value you pass has no JSON form: undefined itself, a function, or a Symbol. When JSON.stringify returns undefined, nothing has thrown, so the problem often shows up later as a missing request body. Fields go missing for related reasons: undefined and function values are skipped inside objects, and only enumerable own string-keyed properties are ever looked at.

When JSON.stringify returns undefined

JSON.stringify returns undefined only when the top-level value can't be written as JSON. MDN's JSON.stringify reference gives JSON.stringify(undefined) and JSON.stringify(() => {}) as examples, and the same applies to a Symbol.

JSON.stringify(undefined);      // undefined
JSON.stringify(() => {});       // undefined
JSON.stringify(Symbol("id"));   // undefined
JSON.stringify(null);           // 'null' (a real string)

Here is how it bites: fetch(url, { method: "POST", body: JSON.stringify(data) }) where data hasn't loaded yet. The body becomes undefined, the request goes out empty, and the server answers with a parse error you then chase on the wrong side.

A replacer can cause it too. The replacer is called first for the root object with an empty-string key, so a replacer that returns undefined for that call wipes out the whole result.

Which values JSON.stringify drops or changes

JSON.stringify drops undefined, functions and Symbols when they are object properties, and turns them into null when they sit in an array. Array positions never shift, which is why the array case uses null instead.

Input Output
{ x: undefined, y: Object, z: Symbol("") } '{}'
{ x: [10, undefined, function () {}, Symbol("")] } '{"x":[10,null,null,null]}'
NaN, Infinity null (never dropped)
new Date() an ISO date string
new Map(...), new Set(...) {}
{ [Symbol("foo")]: "foo" } '{}'
{ x: 2n } throws TypeError

The first two rows are MDN's own examples. NaN and Infinity keep their key and become null; NaN and Infinity in JSON covers why. Dates survive as strings, and the JSON date format guide deals with reading them back.

Why fields vanish from objects and class instances

Fields vanish when they are not enumerable own properties, because those are the only properties JSON.stringify visits. MDN's guide to enumerability and ownership explains the three flags every property has: enumerable or not, string or Symbol key, own or inherited.

Normal assignment and object literals create enumerable properties, so plain objects rarely surprise you. These cases do:

  • Object.defineProperty. Per MDN's defineProperty page, properties added this way are not enumerable by default. MDN's stringify page shows an object with a non-enumerable x and enumerable y coming out as '{"y":"y"}'.
  • Symbol keys. Symbol-keyed properties are ignored completely, even if you list them in a replacer.
  • Map and Set. Their data isn't stored as own properties, so both serialize to {}.
  • Extra properties on arrays. Only indices from 0 to length - 1 are written; a["baz"] = "quux" is lost.

Class getters

MDN's get page notes that getters in classes live on the prototype and are not enumerable, unlike getters in object literals.

class Order {
  constructor(price, qty) { this.price = price; this.qty = qty; }
  get total() { return this.price * this.qty; }
}

JSON.stringify(new Order(5, 2));
// '{"price":5,"qty":2}'  <- no "total"

A plain object with get total() {...} in the literal does keep the field. So code that worked can lose total the day someone refactors it into a class. The fix is toJSON(), below.

When toJSON or a replacer removes data

A toJSON() method or a replacer function can change or remove output on purpose, and a bug in either looks exactly like JSON.stringify losing your data. If an object has toJSON(), its return value is serialized instead of the object. MDN's example object with toJSON() { return this.x + this.y; } stringifies to '11', not an object at all.

Replacers drop any property for which they return undefined, a function or a Symbol. MDN's sample replacer returns undefined for strings, so { foundation: "Mozilla", model: "box", week: 45, transport: "car", month: 7 } comes out as '{"week":45,"month":7}'. A replacer that forgets to return value on its default path drops everything.

A replacer array is a whitelist. JSON.stringify(foo, ["week", "month"]) keeps only those keys, so a field you add later silently goes missing until you add it to the list.

How to keep the fields you need

Pick the fix by cause.

  1. Undefined values you want to keep. JSON has no undefined, so choose null:

    JSON.stringify(data, (key, value) => value === undefined ? null : value);
    
  2. Map and Set. Convert before stringifying: Object.fromEntries(map) for string keys, or Array.from(set).

  3. Class getters and computed fields. Add toJSON() to the class and return a plain object with exactly the fields you want, including total.

  4. Top-level undefined. Guard before you send: if JSON.stringify(x) is undefined, stop and log x.

  5. BigInt. Convert to a string in a replacer, since a BigInt throws TypeError: BigInt value can't be serialized in JSON. The large numbers in JSON.parse post covers reading it back.

To check the result, paste the output into the JSON viewer and expand the tree. A missing key is easier to spot there than in one long line.

My take: Put toJSON() on every class that crosses an API boundary, even when all its fields are plain today. Then the wire format is something you chose, and a refactor can't quietly change it.

These fixes only cover writing. Whatever you swap in, null for undefined or an array for a Set, comes back as that stand-in after JSON.parse, unless you also write a reviver. For the parse side, see JSON.stringify vs JSON.parse.

FAQ

Why does JSON.stringify return undefined instead of a string?

You passed a value JSON can't represent at the top level: undefined, a function or a Symbol. JSON.stringify returns undefined in that case instead of throwing. It can also happen when a replacer returns undefined for the root value.

How do I keep undefined fields in JSON.stringify output?

JSON has no undefined, so you have to pick a stand-in. A replacer that returns null when the value is undefined keeps the key with a null value.

Why does a Map turn into {} with JSON.stringify?

JSON.stringify only visits enumerable own properties, and a Map keeps its entries elsewhere. Convert it first, for example with Object.fromEntries(map) or Array.from(map), or handle it in a replacer.

Why are my class getters missing from the JSON?

Getters declared in a class live on the prototype and are not enumerable, so JSON.stringify skips them. Add a toJSON() method that returns the fields you want.

Sources