Advanced JSON.stringify and JSON.parse: replacer, reviver and toJSON

Intermediate
12 min

Advanced JSON.stringify and JSON.parse: replacer, reviver and toJSON

JSON.stringify(value) and JSON.parse(text) cover everyday work, but real applications hit their limits fast: dates come back as strings, BigInt throws, Map and Set vanish, and circular references crash. After this lesson you will be able to control exactly what gets serialized with a replacer or a toJSON method, and rebuild rich values during parsing with a reviver.

What JSON.stringify Does With Each Value

JSON has fewer types than JavaScript, so some values are dropped or converted:

| JavaScript value | In an object property | In an array | |------------------|-----------------------|-------------| | undefined, function, symbol | omitted | null | | NaN, Infinity | null | null | | Date | ISO 8601 string (via toJSON) | same | | Map, Set | {} | {} | | BigInt, circular reference | TypeError | TypeError |

Only BigInt and cycles throw; everything else fails silently, which is how fields disappear between client and server.

The replacer: Filtering and Transforming on the Way Out

The second argument of JSON.stringify is the replacer. As a function it is called for every key/value pair, starting with the empty key "" for the root. Whatever it returns is serialized; returning undefined omits the property.

javascript
const invoice = { id: 7, total: 1999n, secret: "x", meta: new Map([["ver", 2]]) }; const out = JSON.stringify(invoice, (key, value) => { if (key === "secret") return undefined; if (typeof value === "bigint") return value.toString(); if (value instanceof Map) return Object.fromEntries(value); return value; }); // {"id":7,"total":"1999","meta":{"ver":2}}

Inside the function this is the object that owns the current key. Two details matter: a value's toJSON method runs before the replacer sees it (so a Date arrives already as a string), and the replacer recurses into whatever it returns, so the converted Map entries are visited too. The check key === "password" also removes user.profile.password, because every nesting level is visited.

As an array, the replacer is an allowlist of property names applied at every nesting level: JSON.stringify(user, ["id", "name"]) keeps only those keys throughout the tree. Array elements are never filtered.

toJSON: Letting Objects Describe Themselves

Any object can define toJSON(). JSON.stringify calls it and serializes the return value instead of the object, which is the cleanest way to give a class a stable wire format:

javascript
class Money { constructor(amount, currency) { this.amount = amount; this.currency = currency; } toJSON() { return { amount: this.amount, currency: this.currency, formatted: `${this.amount / 100} ${this.currency}` }; } } JSON.stringify({ total: new Money(199900, "INR") }); // {"total":{"amount":199900,"currency":"INR","formatted":"1999 INR"}}

Date.prototype.toJSON is the built-in example of the same mechanism.

The reviver: Rebuilding Values on the Way In

JSON.parse accepts a reviver as its second argument. It is called for every key/value pair from the innermost values outward, ending with the root under key "". Returning undefined deletes the property. The classic use is turning ISO strings back into Date objects:

javascript
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; const event = JSON.parse('{"title":"Launch","at":"2026-10-01T09:30:00Z"}', (key, value) => typeof value === "string" && ISO.test(value) ? new Date(value) : value ); event.at instanceof Date; // true

Match a strict pattern so ordinary strings are never converted. Recent engines (Node.js 21+, current Chromium browsers) also pass a third context argument whose source property holds the raw text of a primitive, so (k, v, ctx) => k === "id" ? BigInt(ctx.source) : v recovers 9007199254740993 exactly instead of the rounded number.

Handling Circular References

Object graphs with back-references (a parent pointer, an ORM entity) cannot be serialized as-is. For logging, track visited objects in a WeakSet inside the replacer:

javascript
const seen = new WeakSet(); JSON.stringify(graph, (key, val) => { if (typeof val === "object" && val !== null) { if (seen.has(val)) return "[Circular]"; seen.add(val); } return val; });

For data exchange, redesign the shape instead (send parentId rather than parent) so it is a true tree.

Common Mistakes

  • Using JSON.parse(JSON.stringify(obj)) as a deep clone. It drops functions, empties Map and Set, and turns dates into strings. Use structuredClone(obj).
  • Reviving on every parse. A reviver runs for every property and slows large payloads. Convert only the fields that need it.
Quick Quiz
Question 1 of 3

What happens when `JSON.stringify` meets a `BigInt` value without a replacer or `toJSON`?

Key Takeaways

  • JSON.stringify silently drops undefined, functions and symbols, turns NaN into null, and throws on BigInt and cycles.
  • A replacer function filters and converts values on the way out; a replacer array is a key allowlist.
  • toJSON() lets a class define its own wire format and runs before the replacer.
  • A reviver rebuilds rich values such as Date or BigInt during JSON.parse; return undefined to drop a property.
  • Track visited objects in a WeakSet to serialize graphs with circular references.

Next lesson: Working with JSON in Python — load, dump and validate JSON with Python's built-in json module.

Advanced JSON.stringify and JSON.parse: replacer, reviver and toJSON - JSON | CodeYourCraft | CodeYourCraft