JavaScript · lesson 13 of 32
Beginner
13- 1 Introduction to JavaScript
- 2 Setting Up JavaScript Development Environment
- 3 Variables in JavaScript: var, let, and const
- 4 JavaScript Data Types
- 5 JavaScript Operators
- 6 Control Flow in JavaScript
- 7 Functions in JavaScript
- 8 Working with Strings in JavaScript
- 9 Regular Expressions in JavaScript
- 10 Arrays in JavaScript
- 11 Objects in JavaScript
- 12 Map, Set, WeakMap, and WeakSet in JavaScript
- 13 JSON in JavaScript: stringify, parse, and What Gets Lost
Intermediate
15- 14 Error Handling in JavaScript
- 15 Dates and Times in JavaScript: Date Pitfalls and Temporal
- 16 Object-Oriented Programming in JavaScript
- 17 Prototypes and Prototype Chain in JavaScript
- 18 Closures in JavaScript
- 19 this, call, apply, and bind in JavaScript
- 20 JavaScript Event Loop Explained
- 21 Asynchronous JavaScript: Callbacks, Promises, and Async/Await
- 22 Iterators and Generators in JavaScript
- 23 JavaScript Modules: ESM and CommonJS
- 24 DOM Manipulation in JavaScript
- 25 Browser Web APIs in JavaScript
- 26 Files, Streams, and the Command Line in Node.js
- 27 Testing JavaScript with Jest and Vitest
- 32 JavaScript Interview Preparation: Top 35 Questions
Advanced
4Also part of JavaScript
1JSON in JavaScript: stringify, parse, and What Gets Lost
Learn what JSON.stringify actually drops and rewrites — undefined and functions vanish, Date becomes a string, Map becomes {}, BigInt and circular references throw — and how toJSON and a reviver fix it.
By SkillByExample · Published
JSON.stringify and JSON.parse look like simple mirrors of each other — turn an object into text, turn the text back into an object — and for plain data, they are. The surprises start as soon as the object holds anything that isn’t a plain string, number, boolean, array, or nested object of those. Every example below runs with node on Node v24.18.1.
The Basic Round-Trip
For data made only of strings, numbers, booleans, and arrays or objects of those, stringify and parse really are mirrors.
const data = { name: "Alice", age: 30, active: true, tags: ["admin", "user"] };
const json = JSON.stringify(data);
console.log(json);
console.log(typeof json);
const back = JSON.parse(json);
console.log(back);
console.log(back.age + 1);
$ node roundtrip.js
{"name":"Alice","age":30,"active":true,"tags":["admin","user"]}
string
{ name: 'Alice', age: 30, active: true, tags: [ 'admin', 'user' ] }
31
JSON.stringify produces a plain string — typeof json confirms it, not an object. JSON.parse rebuilds an equivalent object from that string, and back.age behaves like the number it started as. This case is the one JSON was designed for. Everything below is a value JSON’s six types (string, number, boolean, null, object, and array) can’t represent directly.
undefined and Functions Vanish — Differently in Objects and Arrays
An object property whose value is undefined or a function is left out of the JSON text entirely. The same two values inside an array are not left out — they become null.
const obj = { a: 1, b: undefined, c: function hello() {}, d: 2 };
console.log(JSON.stringify(obj));
const arr = [1, undefined, function hello() {}, 2];
console.log(JSON.stringify(arr));
$ node dropped.js
{"a":1,"d":2}
[1,null,null,2]
obj’s keys b and c never appear — JSON.stringify just skips a property whose value is undefined or a function. Skipping an array element the same way isn’t an option, because an array’s positions carry meaning: dropping index 1 would shift function hello() {} and 2 one slot to the left, changing what every later index means. JSON.stringify keeps the slot and writes null in its place instead, so arr serializes to 4 elements, not 2.
Date Objects Become Strings — and parse Doesn’t Reverse It
Date has its own toJSON method, which JSON.stringify calls automatically. It returns an ISO 8601 string, not a Date.
const meeting = { title: "Standup", when: new Date(Date.UTC(2026, 0, 15, 12, 30, 0)) };
const json = JSON.stringify(meeting);
console.log(json);
const back = JSON.parse(json);
console.log(typeof back.when, back.when);
console.log(back.when instanceof Date);
$ node dates.js
{"title":"Standup","when":"2026-01-15T12:30:00.000Z"}
string 2026-01-15T12:30:00.000Z
false
meeting.when serializes to the ISO string "2026-01-15T12:30:00.000Z", which is why the JSON text looks like plain text everywhere — nothing in it says “this was a Date”. JSON.parse has no rule for turning that string back into a Date: back.when is a plain string, typeof says so, and back.when instanceof Date is false. Going from Date to JSON loses the type, one-way, unless you tell parse how to get it back — see the reviver section below.
A Map Serializes to an Empty Object
JSON.stringify only looks at an object’s own enumerable properties. A Map keeps its entries somewhere else internally, not as properties, so it has none to find.
const scores = new Map([["alice", 97], ["bob", 72]]);
console.log(JSON.stringify(scores));
console.log(JSON.stringify({ scores }));
// Fix: convert to a plain object or array of pairs first
console.log(JSON.stringify(Object.fromEntries(scores)));
console.log(JSON.stringify([...scores]));
$ node map-json.js
{}
{"scores":{}}
{"alice":97,"bob":72}
[["alice",97],["bob",72]]
Both JSON.stringify(scores) and JSON.stringify({ scores }) print {} — not an error, not a warning, just an empty object standing in for all of alice and bob’s scores, which is exactly why this surprises people coming from “JSON is just an object”: stringify never signals that it had nothing to serialize. Object.fromEntries(scores) turns the Map into a plain object first, so its entries become real properties; spreading into an array of [key, value] pairs ([...scores]) is the shape Map’s own constructor accepts back, round-tripping cleanly through new Map(JSON.parse(json)).
BigInt and Circular References Throw
Two cases don’t quietly lose data — they throw instead.
try {
JSON.stringify({ big: 10n });
} catch (err) {
console.log(err.constructor.name + ":", err.message);
}
const node = { name: "root" };
node.self = node; // a reference to itself
try {
JSON.stringify(node);
} catch (err) {
console.log(err.constructor.name + ":", err.message);
}
$ node throws.js
TypeError: Do not know how to serialize a BigInt
TypeError: Converting circular structure to JSON
--> starting at object with constructor 'Object'
--- property 'self' closes the circle
A BigInt has no JSON representation — the JSON spec (RFC 8259) only warns that large numbers may not survive a round-trip through implementations built on IEEE 754 doubles, it doesn’t mandate that representation, but JSON.stringify still refuses to guess and throws rather than silently rounding a BigInt, with the exact message Do not know how to serialize a BigInt. node.self = node makes node reference itself; walking it to serialize would recurse forever, so JSON.stringify detects the cycle and throws Converting circular structure to JSON instead of hanging. Both are TypeErrors you have to handle before they reach JSON.stringify, not after.
toJSON: Taking Control of Your Own Serialization
Date’s string conversion isn’t a special case baked into JSON.stringify — it’s an ordinary method. If an object has a toJSON method, JSON.stringify calls it and serializes whatever it returns, instead of the object’s own properties. Any class can define one.
class Money {
constructor(cents) {
this.cents = cents;
}
toJSON() {
return `$${(this.cents / 100).toFixed(2)}`;
}
}
const invoice = { item: "Widget", price: new Money(1999) };
console.log(JSON.stringify(invoice));
// Date's own toJSON is why it becomes a string in the first place
console.log(typeof new Date(0).toJSON());
console.log(new Date(0).toJSON());
$ node tojson.js
{"item":"Widget","price":"$19.99"}
string
1970-01-01T00:00:00.000Z
invoice.price is a Money instance holding { cents: 1999 }, but the JSON text shows "$19.99" — JSON.stringify found Money.prototype.toJSON, called it, and serialized its return value instead of price’s actual properties. new Date(0).toJSON() returns a string the same way; JSON.stringify doesn’t know anything special about Date at all, it just always checks for a toJSON method first, on every object, before falling back to its own default.
reviver: Restoring Types JSON.parse Doesn’t Know About
JSON.parse takes an optional second argument, a reviver function, called once for every key-value pair as the result is built. Returning a different value from it changes what ends up in the final object — the fix for the Date problem above.
const payload = JSON.stringify({
title: "Standup",
when: new Date(Date.UTC(2026, 0, 15, 12, 30, 0)),
});
console.log(payload);
const revived = JSON.parse(payload, (key, value) => {
if (key === "when" && typeof value === "string") return new Date(value);
return value;
});
console.log(revived.when instanceof Date, revived.when.toISOString());
console.log(revived.when.getUTCFullYear());
$ node reviver.js
{"title":"Standup","when":"2026-01-15T12:30:00.000Z"}
true 2026-01-15T12:30:00.000Z
2026
The reviver runs on every key as JSON.parse builds the result, bottom-up. When it sees the key "when" holding a string, it constructs a real Date from that string and returns it in place of the string — every other key falls through to return value unchanged. revived.when instanceof Date is now true, and .toISOString() round-trips back to the exact original timestamp. A reviver can’t know on its own which strings are dates without a naming convention or a shape check like this one — JSON itself never tags a value’s intended type.
The replacer Parameter: Filtering What Gets Serialized
JSON.stringify takes an optional second argument too — a replacer, the mirror image of a reviver. Pass a function to transform or omit values during serialization, or pass an array of key names to keep only those keys.
const user = { id: 1, name: "Alice", password: "s3cr3t", email: "alice@example.com" };
// Function replacer: omit a key by returning undefined
const redacted = JSON.stringify(
user,
(key, value) => (key === "password" ? undefined : value),
2 // third argument: indent with 2 spaces
);
console.log(redacted);
// Array replacer: an allow-list of keys, nothing else survives
const summary = JSON.stringify(user, ["id", "name"]);
console.log(summary);
$ node replacer.js
{
"id": 1,
"name": "Alice",
"email": "alice@example.com"
}
{"id":1,"name":"Alice"}
The function replacer runs on every key the same way a reviver does, but during stringify instead of parse — returning undefined for "password" drops it the same way an actual undefined value would, which is why email still comes through untouched. The array form skips the function entirely: ["id", "name"] is a literal allow-list, so password and email are both left out without writing any logic. The optional third argument (2 above) only controls indentation — it has no effect on which keys survive.
Practice
- Set, same problem as Map. Serialize an object holding a
Setof tags and confirm it also becomes{}. Fix it the way the lesson fixedMap.
Solution
const tags = new Set(["admin", "editor"]);
console.log(JSON.stringify({ tags })); // {} — same problem as Map
console.log(JSON.stringify([...tags])); // fix: spread into an array first
$ node p1.js
{"tags":{}}
["admin","editor"]
A Set, like a Map, keeps its members outside of ordinary properties, so JSON.stringify finds nothing to serialize. [...tags] turns it into a real array first, which does serialize.
- A custom
toJSON. Write aTemperatureclass storing degrees Celsius, with atoJSONmethod that returns a string like"22C". ConfirmJSON.stringifyuses it automatically.
Solution
class Temperature {
constructor(celsius) {
this.celsius = celsius;
}
toJSON() {
return `${this.celsius}C`;
}
}
console.log(JSON.stringify({ temp: new Temperature(22) }));
$ node p2.js
{"temp":"22C"}
JSON.stringify checks every object for a toJSON method before falling back to serializing its own properties — a user-defined class gets exactly the same treatment Date does.
- Fix the circular reference without throwing. Write a
safeStringifyfunction that uses a replacer and aWeakSetto detect an object it has already visited, and substitutes the string"[Circular]"instead of throwing.
Solution
function safeStringify(value) {
const seen = new WeakSet();
return JSON.stringify(value, (key, val) => {
if (typeof val === "object" && val !== null) {
if (seen.has(val)) return "[Circular]";
seen.add(val);
}
return val;
});
}
const node = { name: "root" };
node.self = node;
console.log(safeStringify(node));
$ node p3.js
{"name":"root","self":"[Circular]"}
The replacer runs on every key-value pair before it’s serialized, so it gets a chance to intercept self the second time it sees the same object — seen.has(val) is true for node on that second encounter, and "[Circular]" is written instead of recursing back into node again. One caveat: this seen check can’t tell a true cycle from two properties that simply point at the same object without any loop — { a: shared, b: shared } with no cycle at all would still print b as "[Circular]", because the replacer only tracks “have I serialized this object before,” not “does it eventually contain itself.”
- Round-trip a
BigIntthrough a reviver.JSON.stringifycan’t serialize aBigIntdirectly, but it can serialize a string. Convert theBigIntto a string tagged with a trailing"n"before stringifying, then write a reviver that turns that tagged string back into a realBigInt.
Solution
const payload = JSON.stringify({ big: 123456789012345678901234567890n.toString() + "n" });
console.log(payload);
const revived = JSON.parse(payload, (key, value) => {
if (typeof value === "string" && /^\d+n$/.test(value)) {
return BigInt(value.slice(0, -1));
}
return value;
});
console.log(typeof revived.big, revived.big);
$ node p4.js
{"big":"123456789012345678901234567890n"}
bigint 123456789012345678901234567890n
Tagging the string with a trailing "n" before serializing gives the reviver a shape to check for on the way back in — the same trick the Date reviver used, just with a suffix instead of checking the key name. Without some such convention, a plain digit string and a would-be BigInt string look identical to JSON.parse.