Working with JSON
JSON (JavaScript Object Notation) is the universal text format for exchanging structured data between systems — APIs, config files, local storage, and message queues all speak it. Although its syntax looks like a JavaScript object literal, JSON is a strict, language-independent format with its own rules. JavaScript ships two built-in methods, JSON.stringify and JSON.parse, to convert between in-memory values and JSON text. Understanding their options (and their sharp edges) is essential for any data-driven application.
JSON syntax vs JavaScript objects
JSON looks deceptively similar to an object literal, but it is far stricter. Every key must be a double-quoted string, strings must use double quotes (never single), and a handful of JavaScript values simply do not exist in JSON.
{
"name": "Ada",
"age": 36,
"languages": ["JS", "Python"],
"active": true,
"manager": null
}
The differences that trip people up most often:
| Feature | JavaScript object | JSON |
|---|---|---|
| Key quotes | Optional, single or double | Required, double quotes only |
| String quotes | Single, double, or backtick | Double quotes only |
| Trailing commas | Allowed | Not allowed |
| Comments | Allowed | Not allowed |
undefined, functions | Allowed | Not representable |
NaN, Infinity | Allowed | Serialized as null |
Date, Map, Set | Allowed | No native form (lost) |
JSON is a subset of JavaScript’s value space, not a mirror of it. Anything that isn’t a string, number, boolean,
null, array, or plain object needs special handling.
Serializing with JSON.stringify
JSON.stringify(value, replacer?, space?) converts a JavaScript value into a JSON string. Properties with undefined, function, or Symbol values are dropped from objects and become null inside arrays.
const user = {
id: 1,
name: "Ada",
greet: () => "hi", // dropped (function)
nickname: undefined, // dropped
};
console.log(JSON.stringify(user));
Output:
{"id":1,"name":"Ada"}
The replacer argument
The second argument shapes what gets serialized. Pass an array of keys to allowlist properties, or a function to transform each value as it is visited.
const account = { id: 7, name: "Ada", password: "secret", token: "xyz" };
// Array form: only include these keys
console.log(JSON.stringify(account, ["id", "name"]));
// Function form: omit anything that looks sensitive
const safe = JSON.stringify(account, (key, value) =>
["password", "token"].includes(key) ? undefined : value
);
console.log(safe);
Output:
{"id":7,"name":"Ada"}
{"id":7,"name":"Ada"}
The replacer function is called first with an empty key and the whole object, then recursively for every property. Returning undefined removes that property.
Pretty-printing with space
The third argument controls indentation. Pass a number (spaces per level, max 10) or a string (used literally).
const data = { id: 1, tags: ["a", "b"] };
console.log(JSON.stringify(data, null, 2));
Output:
{
"id": 1,
"tags": [
"a",
"b"
]
}
Customizing output with toJSON
If a value has a toJSON() method, JSON.stringify calls it and serializes the return value instead. This is how Date produces an ISO string, and you can use it on your own classes.
class Money {
constructor(cents) { this.cents = cents; }
toJSON() { return (this.cents / 100).toFixed(2); }
}
console.log(JSON.stringify({ price: new Money(1599), at: new Date("2026-06-01") }));
Output:
{"price":"15.99","at":"2026-06-01T00:00:00.000Z"}
Parsing with JSON.parse
JSON.parse(text, reviver?) turns a JSON string back into a JavaScript value. Invalid JSON throws a SyntaxError, so untrusted input should be wrapped in try/catch.
const json = '{"id":1,"created":"2026-06-01T00:00:00.000Z"}';
const obj = JSON.parse(json);
console.log(typeof obj.created); // still a string!
Output:
string
The reviver argument
The optional reviver function lets you transform values during parsing — the natural place to rehydrate dates or other rich types. It runs bottom-up, visiting nested values before their parents.
const json = '{"id":1,"created":"2026-06-01T00:00:00.000Z"}';
const obj = JSON.parse(json, (key, value) =>
key === "created" ? new Date(value) : value
);
console.log(obj.created instanceof Date);
Output:
true
Deep-clone caveats
A popular trick is JSON.parse(JSON.stringify(obj)) to deep-clone an object. It works for plain JSON-safe data but silently corrupts anything else: functions and undefined vanish, Date becomes a string, Map/Set become {}, and circular references throw.
const original = { when: new Date(), items: new Set([1, 2]), fn: () => 1 };
console.log(JSON.parse(JSON.stringify(original)));
Output:
{ when: '2026-06-01T...Z', items: {} }
For real deep cloning, prefer the built-in
structuredClone(value)(Node 17+, all modern browsers). It preservesDate,Map,Set, typed arrays, and handles cycles — though it still cannot clone functions.
Common errors
- Unexpected token — usually trailing commas, single quotes, or unquoted keys in the source text.
- Circular structure —
JSON.stringifythrowsTypeError: Converting circular structure to JSONwhen an object references itself. - Lost precision —
BigIntcannot be serialized and throws; large integers beyondNumber.MAX_SAFE_INTEGERlose precision on parse. - Empty string —
JSON.parse("")throws; guard against empty bodies before parsing API responses.
Best Practices
- Always wrap
JSON.parseof external input intry/catchand validate the shape before trusting it. - Use a replacer (or a dedicated DTO) to strip secrets like passwords and tokens before sending data over the wire.
- Reach for
structuredCloneinstead of the stringify/parse clone hack for non-trivial objects. - Use a reviver to convert ISO date strings back into
Dateobjects at the parse boundary, not scattered through your code. - Pretty-print with
JSON.stringify(value, null, 2)for human-facing files and logs; keep wire payloads compact. - Define
toJSON()on domain classes so serialization stays consistent and centralized. - Never assume
JSON.stringifyround-trips perfectly — test withundefined,Date, and nested structures.