JSON is the default data format of the web, and JavaScript ships with exactly two functions to move between JSON text and live objects: JSON.parse and JSON.stringify. They look trivial, but both accept optional callbacks most developers never touch, and both have sharp edges around dates, BigInt, circular references, and values that silently disappear.
This guide covers the full API surface: the reviver and replacer callbacks, the space argument for pretty-printing, toJSON(), why dates need special handling, the limits of the JSON round-trip for deep cloning, and how to parse untrusted input without crashing your app. Every section includes runnable code.
The two functions behind JSON in JavaScript
Working with JSON in JavaScript comes down to two built-in methods on the global JSON object. JSON.parse(text) reads a JSON string and returns the corresponding JavaScript value — object, array, string, number, boolean, or null. JSON.stringify(value) does the reverse, serializing a value into a JSON string. Neither method touches the network or the filesystem; they are pure, synchronous conversions between text and in-memory data.
const text = '{"name":"Ada","active":true,"score":42}';
const obj = JSON.parse(text);
console.log(obj.name); // "Ada"
console.log(obj.score); // 42 (a real number, not a string)
const backToText = JSON.stringify(obj);
console.log(backToText); // {"name":"Ada","active":true,"score":42}That is 90% of everyday usage. The remaining 10% — the optional second and third arguments, and the edge cases around types JSON cannot represent — is where bugs hide. If you are new to the format itself, start with what is JSON and the JSON data types primer, then come back here.
JSON.parse and the reviver callback
JSON.parse accepts an optional second argument called the reviver: a function (key, value) => newValue invoked for every key/value pair, working from the innermost values outward. Return the value unchanged to keep it, return something new to transform it, or return undefined to drop the property. The reviver is the idiomatic place to rehydrate types JSON has no native syntax for.
Turning date strings back into Date objects
JSON has no date type, so dates are almost always serialized as ISO 8601 strings. When you parse them back, they are still strings. A reviver can detect and upgrade them:
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
function dateReviver(key, value) {
if (typeof value === "string" && ISO_DATE.test(value)) {
return new Date(value);
}
return value;
}
const json = '{"title":"Launch","start":"2026-07-12T09:00:00.000Z"}';
const event = JSON.parse(json, dateReviver);
console.log(event.start instanceof Date); // true
console.log(event.start.getFullYear()); // 2026The key argument is the property name (or the array index as a string); the top-level value is passed last with an empty-string key. Because the reviver walks bottom-up, nested objects are already transformed by the time their parent is visited.
JSON.stringify: space, replacer, and toJSON()
JSON.stringify(value, replacer, space) takes two optional arguments that control what gets serialized and how it is formatted.
The space argument for pretty-printing
Pass a number to indent with that many spaces (values above 10 are clamped to 10), or a string to indent with that literal string. This is exactly what a JSON formatter does under the hood.
const config = { host: "localhost", port: 8080, tls: false };
console.log(JSON.stringify(config, null, 2));
// {
// "host": "localhost",
// "port": 8080,
// "tls": false
// }
console.log(JSON.stringify(config, null, "\t")); // tab-indentedThe replacer: filtering and transforming
The replacer mirrors the reviver but runs during serialization. As a function (key, value) => newValue, it can omit or rewrite values; returning undefined drops the property. As an array of keys, it acts as an allowlist of properties to keep — useful for stripping sensitive fields.
const user = { id: 1, name: "Ada", password: "secret", token: "abc" };
// Function form: redact sensitive keys
const safe = JSON.stringify(user, (key, value) =>
key === "password" || key === "token" ? undefined : value
);
console.log(safe); // {"id":1,"name":"Ada"}
// Array form: keep only these keys
console.log(JSON.stringify(user, ["id", "name"]));
// {"id":1,"name":"Ada"}toJSON(): let an object serialize itself
If a value has a toJSON() method, JSON.stringify calls it and serializes the returned value instead of the object. This is exactly why dates "just work" when stringifying — Date.prototype.toJSON returns an ISO string. You can add the same hook to your own classes:
class Money {
constructor(cents) { this.cents = cents; }
toJSON() { return (this.cents / 100).toFixed(2); }
}
console.log(JSON.stringify({ price: new Money(1999) }));
// {"price":"19.99"}What JSON.stringify silently drops or breaks
JSON.stringify cannot represent every JavaScript value, and its failure modes differ: some values vanish quietly, others throw. Knowing which is which prevents data-loss bugs that only surface in production.
| Value type | What happens |
|---|---|
undefined | Dropped from objects; becomes null inside arrays |
| Function | Dropped from objects; becomes null inside arrays |
Symbol | Dropped entirely |
NaN, Infinity | Serialized as null |
BigInt | Throws a TypeError |
| Circular reference | Throws a TypeError |
Date | Becomes an ISO string (via toJSON) |
Map, Set | Becomes {} — contents are lost |
// BigInt -> TypeError: Do not know how to serialize a BigInt
try {
JSON.stringify({ big: 9007199254740993n });
} catch (e) {
console.log(e.name); // "TypeError"
}
// Circular reference -> TypeError
const node = { name: "a" };
node.self = node;
try {
JSON.stringify(node);
} catch (e) {
console.log(e.name); // "TypeError"
}
// A replacer can serialize BigInt as a string
const out = JSON.stringify({ big: 42n }, (key, value) =>
typeof value === "bigint" ? value.toString() : value
);
console.log(out); // {"big":"42"}The BigInt limitation matters when parsing, too: JSON numbers larger than Number.MAX_SAFE_INTEGER (2^53 − 1) lose precision when read by JSON.parse, because they are coerced to IEEE-754 doubles. If your API returns 64-bit IDs, transport them as strings.
Deep copies: structuredClone vs the JSON round-trip
For years the go-to trick for deep-cloning a plain object was JSON.parse(JSON.stringify(obj)). It works for JSON-safe data, but it inherits every limitation above: dates become strings, Map/Set become empty objects, undefined and functions disappear, and BigInt or circular references throw. Modern JavaScript has a purpose-built alternative, structuredClone(), available in all current browsers and in Node 17+.
const original = {
when: new Date("2026-01-01"),
tags: new Set(["a", "b"]),
note: undefined,
};
// JSON round-trip: lossy
const viaJson = JSON.parse(JSON.stringify(original));
console.log(viaJson.when instanceof Date); // false (it's a string)
console.log(viaJson.tags); // {} (Set is gone)
console.log("note" in viaJson); // false (undefined dropped)
// structuredClone: type-preserving
const clone = structuredClone(original);
console.log(clone.when instanceof Date); // true
console.log(clone.tags instanceof Set); // true
console.log(clone.when === original.when); // false (a real copy)Fetching and safely parsing JSON
Most JSON in a real app arrives over the network. The fetch API gives you a Response with a .json() method that reads the body and runs JSON.parse for you, returning a promise. Because it can reject — on a network failure or on a body that is not valid JSON — you should always guard it.
async function getUser(id) {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
throw new Error(`Request failed: ${res.status}`);
}
// res.json() rejects if the body isn't valid JSON
return res.json();
}
getUser(1)
.then((user) => console.log(user.name))
.catch((err) => console.error("Could not load user:", err));When you call JSON.parse directly on a string — from localStorage, a query param, a message event, or a file — invalid input throws a SyntaxError. Never trust that a stored or incoming string is well-formed. A small wrapper keeps the failure local instead of crashing the caller:
function safeParse(text, fallback = null) {
try {
return JSON.parse(text);
} catch {
return fallback;
}
}
const prefs = safeParse(localStorage.getItem("prefs"), {});
console.log(prefs); // {} if the stored value was missing or corruptWhen you are debugging a stringified payload by hand, a formatter makes the structure readable instantly — it is essentially JSON.stringify(value, null, 2) plus validation and syntax highlighting. Once your objects are flowing correctly, the JSON Minifier does the opposite for production payloads.