Lesson 39 of 80
Everyone knows JSON.stringify(obj) and JSON.parse(str). Almost nobody knows the second and third arguments, or that objects can control their own serialization.
14 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
JSON.stringify and JSON.parse both take a second argument most people never touch — and stringify takes a third. Those extra arguments turn "convert to/from JSON" into "control exactly what gets converted and how." On top of that, any object can hook into stringify itself by defining a toJSON() method, which stringify calls automatically before doing anything else.
space — the third argument to stringify — controls pretty-printing:
JSON.stringify({ a: 1, b: 2 }, null, 2);
// '{\n "a": 1,\n "b": 2\n}'
replacer — the second argument — filters or transforms values as they're serialized. As an array, it's an allowlist of keys:
const user = { id: 1, name: "Dee", passwordHash: "x8f2..." };
JSON.stringify(user, ["id", "name"]);
// '{"id":1,"name":"Dee"}' -- passwordHash never makes it out
As a function, it runs on every key/value pair, including the root object under key "", and can transform or omit values:
JSON.stringify(user, (key, value) =>
key === "passwordHash" ? undefined : value
);
// '{"id":1,"name":"Dee"}' -- same result, more flexible logic
reviver — the second argument to parse — runs on every key/value pair as the string is parsed, letting you reconstruct richer types:
const json = '{"name":"Dee","joined":"2023-01-01T00:00:00.000Z"}';
JSON.parse(json, (key, value) =>
key === "joined" ? new Date(value) : value
);
// { name: "Dee", joined: Date object }
toJSON() — if an object has this method, stringify calls it and serializes its return value instead of the object itself, automatically, with no replacer needed:
class Money {
constructor(cents) { this.cents = cents; }
toJSON() { return (this.cents / 100).toFixed(2); }
}
JSON.stringify({ price: new Money(1999) });
// '{"price":"19.99"}'
This is exactly how Date itself works — Date.prototype.toJSON is why dates come out as ISO strings instead of {}.
stringify doesn't error on unsupported values — it drops or nulls them. functions, undefined, and Symbol values are omitted entirely from objects (or turned into null inside arrays), with zero warning. JSON.stringify({ f: () => {}, a: undefined }) gives '{}' — both keys vanish silently.key/value pair as it walks the tree — it has no automatic access to sibling keys or the parent object, only what's passed as this inside the callback (the object currently being processed).parse's reviver sees leaf values before their parent object, so if you need to transform a whole subtree based on a sibling key, you generally need to do that work in the parent's callback, not the child's.This is the same gap structured cloning was built to close — JSON.stringify and JSON.parse were never a general serialization mechanism, just a JSON-shaped subset with toJSON as its one escape hatch for custom types. Understanding exactly what gets dropped here is what makes it obvious why structuredClone() exists as a separate, more complete tool.