How to Stop JSON Flattening Your Objects
Fix JSON flattening in JavaScript with replacers, revivers, toJSON, and context.source to restore Date, Map, Set, and BigInt accurately.
JSON.stringify converts a Date by calling its toJSON method, which returns an ISO 8601 string, and JSON.parse has no matching step, so the value comes back as a string unless you convert it yourself with a reviver.
It usually turns up the same way: a cached object goes into localStorage fine, comes back out fine, and then .getFullYear() throws or a table cell renders Invalid Date. The data was never corrupted. It just stopped being a Date somewhere between the two calls.
This article covers both halves of the trip: toJSON and the replacer on the way out, the reviver on the way back, and the reviver’s third argument for values that lose precision before you ever see them. It assumes you already know the basics; if you want those first, see how to read and write JSON in JavaScript. This piece starts at the second argument.
Key Takeaways
JSON.stringifyserializes aDatethroughtoJSONas an ISO string, andJSON.parsereturns that string unchanged unless a reviver converts it back.- Return
undefinedfrom a reviver and that key drops out of the result, so every reviver needs a finalreturn valuefor the keys it does not handle. - The reviver runs on every key-value pair, children before their parent, and then once more on the whole parsed value under the key
"". MapandSetserialize to{}, so restoring them requires a replacer and a reviver written as a matched pair.- The reviver’s third argument is a context object whose
sourceproperty holds the original JSON text, which lets you read a large integer as aBigIntbeforeNumberrounds it.
What Does the Broken JSON Round Trip Look Like?
const session = { user: "ada", lastLogin: new Date("2024-03-01T09:30:00Z") };
const wire = JSON.stringify(session);
// '{"user":"ada","lastLogin":"2024-03-01T09:30:00.000Z"}'
const back = JSON.parse(wire);
typeof back.lastLogin; // "string"
back.lastLogin.getFullYear(); // TypeError
The outbound conversion is fine. It is the inbound one that has no idea what the string used to be.
What Survives JSON Serialization, and What Does Not?
JSON’s grammar has no slot for most of what a JavaScript object holds, so anything outside it is converted or dropped. MDN documents the full set of serialization rules for JSON.stringify; the third column below is what you actually get back after a parse.
| Value | JSON.stringify writes | JSON.parse returns |
|---|---|---|
Date | ISO string via toJSON | string |
Map, Set, WeakMap, WeakSet | {} | empty object |
undefined, function, symbol in an object | property omitted | property absent |
| The same values inside an array | null | null |
NaN, Infinity | null | null |
BigInt | throws TypeError | n/a |
| Class instance | plain object of enumerable own properties | plain object, prototype lost |
| Symbol-keyed property | ignored | absent |
| Circular reference | throws TypeError | n/a |
Boxed Number, String, Boolean | unwrapped primitive | primitive |
Two rows deserve emphasis. Map and Set come out as {} because JSON.stringify walks an object’s own enumerable properties, and their entries do not live there. And inside an object, undefined, functions and symbol values are omitted entirely, while inside an array the same values become null, so the indexes survive even though the values do not.
toJSON Decides What Gets Written
When a value has a toJSON method, JSON.stringify writes whatever that method hands back and ignores the object itself. MDN’s toJSON example also shows the method being handed the key its value sits under, so one object can come out differently depending on where it appears.
class Money {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
format() {
return `${(this.amount / 100).toFixed(2)} ${this.currency}`;
}
toJSON() {
return { __type: "Money", amount: this.amount, currency: this.currency };
}
}
JSON.stringify({ total: new Money(4599, "EUR") });
// '{"total":{"__type":"Money","amount":4599,"currency":"EUR"}}'
The __type field is the discriminator the reviver will look for. Writing it is the outbound half of the contract.
The JSON.parse Reviver Runs on the Way Back
The reviver is the second argument to JSON.parse, and it is called for every key-value pair produced by parsing. MDN’s traversal example shows the order: the deepest values go first, then whatever contains them, and one last call covers the whole result under the key "".
JSON.parse('{"a":1,"b":{"c":2,"d":{"e":3}}}', (key, value) => {
console.log(JSON.stringify(key));
return value;
});
// "a", "c", "e", "d", "b", ""
Now the rule that quietly destroys data: return undefined from a reviver and that key drops out of the object; do it on the root call and the whole parse comes back as undefined.
const json = '{"user":"ada","lastLogin":"2024-03-01T09:30:00.000Z"}';
// Destructive: every unhandled key falls off the end and is deleted.
JSON.parse(json, (key, value) => {
if (key === "lastLogin") return new Date(value);
});
// undefined
// Correct: the fallback return keeps everything else intact.
JSON.parse(json, (key, value) =>
key === "lastLogin" ? new Date(value) : value,
);
// { user: "ada", lastLogin: Date 2024-03-01T09:30:00.000Z }
Neither version throws. That is what makes the first one dangerous: the loss shows up as a missing field or a raw ISO string in rendered output rather than as a stack trace, which is the kind of defect a session replay surfaces long before a bug report names it.
How Do You Restore Class Instances and Maps?
Restoring a real instance takes both halves of the trip: toJSON writes a type tag alongside the data, and the reviver checks for that tag and passes the remaining fields to the constructor.
const reviver = (key, value) =>
value && value.__type === "Money"
? new Money(value.amount, value.currency)
: value;
JSON.parse('{"total":{"__type":"Money","amount":4599,"currency":"EUR"}}', reviver)
.total.format(); // "45.99 EUR"
The same pattern works for built-ins that have no toJSON. A Map goes out as an entries array via a replacer and comes back through a reviver that recognizes an array of arrays.
const flags = new Map([["beta", true], ["darkMode", false]]);
const text = JSON.stringify({ flags }, (key, value) =>
value instanceof Map ? Array.from(value.entries()) : value,
);
// '{"flags":[["beta",true],["darkMode",false]]}'
const restored = JSON.parse(text, (key, value) =>
Array.isArray(value) && value.every(Array.isArray) ? new Map(value) : value,
);
restored.flags.get("beta"); // true
That shape test is a guess, and it misfires on empty arrays: [].every(Array.isArray) is true, so a plain [] anywhere in the payload comes back as an empty Map. A type tag, like the one Money writes, removes the guesswork.
Replacer and reviver are one agreement about a wire format. Change either side alone and the round trip breaks.
The Replacer: Filtering on the Way Out
The replacer is the second argument to JSON.stringify and takes two forms. As a function it runs for every key-value pair and returning undefined omits the property. As an array it acts as an allow-list, where only string and number entries count and anything else you put in the list, symbols included, has no effect at all.
const account = { id: 7, email: "ada@example.com", password: "hunter2" };
JSON.stringify(account, (key, value) => (key === "password" ? undefined : value));
// '{"id":7,"email":"ada@example.com"}'
JSON.stringify(account, ["id", "email"]);
// '{"id":7,"email":"ada@example.com"}'
The same technique drops a known back-reference key that would otherwise make JSON.stringify throw a TypeError on a cycle. A general cycle-safe serializer needs a WeakSet of visited objects; dropping one named key only handles the case you know about.
One timing detail matters: toJSON runs before the replacer sees a value, so for a Date the replacer’s value argument is already the ISO string while this[key] is still the original object.
JSON.stringify({ lastLogin: new Date() }, function (key, value) {
// Must be a regular function: an arrow function has no `this` binding here.
return key === "lastLogin" ? this[key].getTime() : value;
});
The third argument, space, only affects formatting. Ask for more than 10 spaces and you still get 10, and an indent string longer than 10 characters is cut down to its first 10.
Reading the Original Text with context.source
The reviver’s third argument is a context object, built afresh for each call, whose source property holds the original JSON text for the value. That argument turns up for primitives only; an object or an array gets nothing. This is the TC39 JSON.parse source text access proposal, which reached Stage 4 and shipped in ECMAScript 2026, approved by Ecma International on 30 June 2026.
It solves a loss that happens before any reviver could intervene: by the time you receive value, a large integer has already been rounded into a double.
const wire = '{"orderId": 9007199254740993}';
JSON.parse(wire).orderId;
// 9007199254740992 <- precision already gone
JSON.parse(wire, (key, value, context) =>
key === "orderId" ? BigInt(context.source) : value,
).orderId;
// 9007199254740993n
value is the lossy product. context.source is what was actually on the wire. Check availability in your target runtimes before relying on it.
Wrapping Up
Serialization is a contract you write twice: once in toJSON or a replacer, once in a reviver that understands what the first half produced. Go through the objects you push into localStorage or a cache layer, find the ones carrying Date, Map, Set or class instances, and give each a type tag and a matching reviver branch. Then check that every reviver you already have ends with a fallback return value.
FAQs
Does structuredClone remove the need for a reviver?
No, because structuredClone produces an in-memory copy rather than a JSON string, so it cannot be written to localStorage or a request body. It does preserve Date, Map, Set and circular references, but it throws a DataCloneError on functions and does not copy the prototype chain, so a class instance still arrives as a plain object without its methods. Restoring instances from text still needs a reviver.
Should I use toJSON or a replacer function?
Use toJSON when the type owns its wire format: the method lives on the class, so every serialization of that value emits the same shape without the caller doing anything. Use a replacer when the rule belongs to one call site, such as stripping a password field or converting a Map from a library you do not control. toJSON runs first, so the replacer receives whatever toJSON returned.
Does the reviver run on array elements too?
Yes. Array indexes are passed to the reviver as strings, so the first element arrives with the key '0', and the array itself is then passed up under its own key. Returning undefined for an element deletes that element instead of shifting the rest, leaving a hole while the array length stays unchanged. Array revivers need the same fallback return value that object revivers do.
How do I type a JSON.parse reviver in TypeScript?
JSON.parse returns any in TypeScript regardless of what the reviver does. The standard library declares the reviver with a string key, an any value and an any return type, so a reviver that rebuilds Date or class instances gives the compiler no extra information. Annotate the result with an explicit type at the call site, or run the parsed value through a schema validator before trusting its shape.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k