Skip to main content
JavaScript · lesson 13 of 32
JavaScript beginner Lesson 13 of 32

JSON 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

javascript json serialization

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

  1. Set, same problem as Map. Serialize an object holding a Set of tags and confirm it also becomes {}. Fix it the way the lesson fixed Map.
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.

  1. A custom toJSON. Write a Temperature class storing degrees Celsius, with a toJSON method that returns a string like "22C". Confirm JSON.stringify uses 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.

  1. Fix the circular reference without throwing. Write a safeStringify function that uses a replacer and a WeakSet to 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.”

  1. Round-trip a BigInt through a reviver. JSON.stringify can’t serialize a BigInt directly, but it can serialize a string. Convert the BigInt to a string tagged with a trailing "n" before stringifying, then write a reviver that turns that tagged string back into a real BigInt.
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.

Frequently Asked Questions

Why does JSON.stringify drop a property but turn the same value into null in an array?
An object property that is undefined or a function is simply omitted — the key never appears in the output. The same value at an array index becomes null instead, because an array's positions carry meaning: dropping an index would shift every element after it, so JSON.stringify keeps the slot and writes null in its place.
Why doesn't JSON.parse turn an ISO date string back into a Date object?
JSON.stringify calls a Date's own toJSON method, which returns an ISO string, and from that point JSON only sees a string — it has no type tag marking it as a date. JSON.parse has no way to know that string should become a Date again unless you pass it a reviver function that checks the key or the string's shape and constructs the Date yourself.
How do I serialize a Map or a Set as JSON?
JSON.stringify only looks at an object's own enumerable properties, and a Map or Set keeps its entries internally rather than as properties, so both serialize to {}. Convert first: Object.fromEntries(map) or [...map] for a Map, and [...set] for a Set, then stringify the result.
What causes 'TypeError: Converting circular structure to JSON' and how do I avoid it?
It happens when an object (directly or through a chain of properties) references itself, because JSON.stringify would otherwise recurse forever trying to serialize it. Break the cycle before serializing — remove or replace the circular property — or pass a replacer function that tracks visited objects (with a WeakSet, for example) and substitutes a placeholder like "[Circular]" the second time it sees one.
What's the difference between stringify's replacer and parse's reviver?
A replacer runs during JSON.stringify, once per key-value pair, and can return a different value, omit the key by returning undefined, or run a key allow-list by passing an array instead of a function. A reviver runs during JSON.parse, also once per key-value pair, but after the JSON text has already been parsed into primitives — it can turn a string or number back into a richer type like a Date before the final result is returned.