Skip to content
LocalOnly
JSON Tools

JSON.stringify Explained: undefined, Dates and BigInt

Most surprises from JSON.stringify are silent: a field disappears, a Map becomes {}, a date turns into a string. Here is exactly what happens to each kind of value, and how to control it.

Updated October 18, 20264 min readTool runs in your browser

JSON.stringify looks like the simplest function in JavaScript, but a JavaScript value can hold far more kinds of data than JSON can. For each kind that JSON lacks, JSON.stringify makes a choice. It drops the value, converts it, or throws, and only the last one tells you anything happened.

This guide lists those choices, each checked in Node, then covers the two hooks that let you change them: toJSON and the replacer. The JSON Stringify tool covers a different job, turning a JSON document into an escaped string.

Values that disappear

undefined, functions and symbols have no JSON form. Inside an object the whole property is left out. Inside an array the slot stays, as null, so positions do not shift:

JSON.stringify({ a: 1, b: undefined, c: () => 1, d: Symbol("x") })
// '{"a":1}'

JSON.stringify([1, undefined, () => 1, Symbol("x")])
// '[1,null,null,null]'

JSON.stringify(undefined)
// undefined, not a string

The last case catches people out. Code that writes JSON.stringify(value) to a file or a response body can end up writing the text undefined, or nothing at all, when value was never set.

Values that change silently

Input                                   Output
{ a: NaN, b: Infinity, c: -Infinity }   {"a":null,"b":null,"c":null}
{ at: new Date("2026-10-18T09:15Z") }   {"at":"2026-10-18T09:15:00.000Z"}
{ at: new Date("not a date") }          {"at":null}
{ m: new Map([["a", 1]]) }              {"m":{}}
{ s: new Set([1, 2]) }                  {"s":{}}
{ z: -0 }                               {"z":0}
new Money(5, "EUR")                     {"amount":5,"currency":"EUR"}
new Error("timeout")                    {}
  • A Date becomes an ISO string through its built-in toJSON method, and JSON.parse does not turn it back into a date. An invalid date becomes null.
  • A Map or Set becomes {}, because only an object's own enumerable properties are written, and a Map keeps its entries elsewhere. The data is lost without an error.
  • A class instance keeps its fields but loses its class. Parsing the result gives a plain object, without the methods.
  • An Error becomes {}, because its message and stack are not enumerable. Log { message: error.message, stack: error.stack } instead, or use a logger that handles errors itself.

Values that throw

Two kinds of value stop serialisation with a TypeError:

JSON.stringify({ id: 12345678901234567890n })
// TypeError: Do not know how to serialize a BigInt

const order = { id: "ord_1" };
order.self = order;
JSON.stringify(order)
// TypeError: Converting circular structure to JSON

Cycles appear more often than you might expect: ORM entities with links in both directions, DOM nodes, and Node's HTTP request and socket objects. Logging one of these with JSON.stringify is a common way to turn a handled error into an unhandled one.

JSON.stringify replacer and toJSON

An object with a toJSON method is replaced by whatever that method returns, which is how Date works. Add one to your own classes to choose their JSON form:

const price = { amount: 5, currency: "EUR", toJSON() { return "5.00 EUR"; } };
JSON.stringify({ price })
// '{"price":"5.00 EUR"}'

A replacer function is called for every key and value, starting with the root under the empty key "". It sees each value after toJSON has run, so for a date it receives the string, not the Date. Return undefined to drop a property. Three replacers that solve the problems above:

// BigInt: write it as a string
JSON.stringify(data, (key, value) => (typeof value === "bigint" ? value.toString() : value));

// Map and Set: convert them instead of losing them
JSON.stringify(data, (key, value) => {
  if (value instanceof Map) return Object.fromEntries(value);
  if (value instanceof Set) return [...value];
  return value;
});

// Secrets: drop a key at every depth
JSON.stringify(data, (key, value) => (key === "password" ? undefined : value));

The well-known fix for cycles, a replacer that remembers every object in a WeakSet and replaces repeats with "[Circular]", has a flaw. It also replaces objects that are shared but not circular: with { billing: address, shipping: address }, shipping comes out as "[Circular]". For logging that may be acceptable. For data, remove the back-references before serialising.

The replacer can also be an array of key names. Only those keys are written, at every depth, so nested keys must be listed too: with ["id", "address"], the address object comes out empty unless city is also in the list.

Pretty printing with the space argument

The third argument sets indentation. A number means that many spaces, but values above 10 are treated as 10. A string is used as the indent itself, cut to its first 10 characters:

JSON.stringify({ a: [1] }, null, "--")
// {
// --"a": [
// ----1
// --]
// }

Use 2 or "\t" for files people read, and leave the argument out for anything sent over a network or written to a log.

Frequently asked questions

Command Palette

Search for a tool or command