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.
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 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.
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.
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:
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.
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:
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; // trueMatch 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.
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:
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.
JSON.parse(JSON.stringify(obj)) as a deep clone. It drops functions, empties Map and Set, and turns dates into strings. Use structuredClone(obj).What happens when `JSON.stringify` meets a `BigInt` value without a replacer or `toJSON`?
JSON.stringify silently drops undefined, functions and symbols, turns NaN into null, and throws on BigInt and cycles.toJSON() lets a class define its own wire format and runs before the replacer.Date or BigInt during JSON.parse; return undefined to drop a property.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.