JavaScript JSON Handling Cheat Sheet
Covers converting between JavaScript values and JSON strings with JSON.stringify and JSON.parse, plus custom serialization and common gotchas.
JSON.stringify
Serialize JavaScript values into a JSON string.
const user = { name: "Alice", age: 30, active: true };JSON.stringify(user);// '{"name":"Alice","age":30,"active":true}'JSON.stringify(user, null, 2);// Pretty-printed with 2-space indentationJSON.stringify(user, ["name", "age"]);// '{"name":"Alice","age":30}' -- replacer array whitelists keysJSON.stringify(user, (key, value) => typeof value === "number" ? value * 2 : value);// Replacer function transforms values before serialization
JSON.parse
Deserialize a JSON string back into JavaScript values.
const raw = '{"name":"Alice","age":30}';const user = JSON.parse(raw);user.name; // "Alice"// Reviver function to transform values while parsingconst withDate = JSON.parse( '{"createdAt":"2024-01-01T00:00:00Z"}', (key, value) => key === "createdAt" ? new Date(value) : value);withDate.createdAt instanceof Date; // true// Invalid JSON throws a SyntaxError -- always wrap in try/catch for untrusted inputtry { JSON.parse("{invalid}");} catch (e) { console.error("Bad JSON:", e.message);}
Common Gotchas
Values that don't round-trip the way you'd expect.
JSON.stringify(undefined); // undefined (not a string!)JSON.stringify({ a: undefined }); // '{}' -- undefined values are droppedJSON.stringify([undefined, 1]); // '[null,1]' -- undefined becomes null in arraysJSON.stringify({ fn() {} }); // '{}' -- functions are omittedJSON.stringify(NaN); // 'null'JSON.stringify(Infinity); // 'null'const circular = {};circular.self = circular;JSON.stringify(circular); // Throws: TypeError - Converting circular structure to JSONJSON.stringify({ d: new Date() });// Dates serialize via their toJSON() method -> ISO 8601 string
Custom Serialization with toJSON
Control how an object serializes by defining toJSON().
class Money { constructor(cents) { this.cents = cents; } toJSON() { // JSON.stringify calls toJSON() automatically if present return { amount: this.cents / 100, currency: "USD" }; }}JSON.stringify({ price: new Money(1999) });// '{"price":{"amount":19.99,"currency":"USD"}}'
Quick Reference
Related APIs and reminders.
- structuredClone(obj)- Deep-clones an object (handles Dates, Maps, etc.), a better alternative to JSON round-tripping for cloning
- JSON.stringify(x) === undefined- Happens for undefined, functions, and symbols passed at the top level
- response.json()- Fetch API helper that parses a Response body as JSON, returns a Promise
- Content-Type: application/json- Required request header so servers correctly parse a JSON body
- try/catch around JSON.parse- Mandatory when parsing external or user-supplied strings
Serializing Map, Set, and BigInt
None of these round-trip through JSON natively -- convert them explicitly with a replacer/reviver pair.
function replacer(key, value) { if (value instanceof Map) { return { __type: "Map", entries: [...value.entries()] }; } if (value instanceof Set) { return { __type: "Set", values: [...value.values()] }; } if (typeof value === "bigint") { return { __type: "BigInt", value: value.toString() }; } return value;}function reviver(key, value) { if (value && typeof value === "object") { if (value.__type === "Map") return new Map(value.entries); if (value.__type === "Set") return new Set(value.values); if (value.__type === "BigInt") return BigInt(value.value); } return value;}const payload = { tags: new Set(["a", "b"]), counts: new Map([["x", 1]]), big: 9007199254740993n };const json = JSON.stringify(payload, replacer);const restored = JSON.parse(json, reviver);restored.tags instanceof Set; // true// Note: JSON.stringify(bigintValue) throws TypeError directly -- BigInt is not// serializable without a replacer, unlike undefined/functions which are silently dropped.
Prototype Pollution via JSON.parse
Untrusted JSON with __proto__ or constructor keys can pollute Object.prototype when merged carelessly.
const malicious = '{"__proto__": {"isAdmin": true}}';// JSON.parse itself is safe -- __proto__ becomes a plain own property, not the prototypeconst obj = JSON.parse(malicious);Object.getPrototypeOf(obj) === Object.prototype; // true, unaffected// The danger is a NAIVE deep-merge/assign afterwards:function unsafeMerge(target, source) { for (const key in source) { if (typeof source[key] === "object") { target[key] = target[key] || {}; unsafeMerge(target[key], source[key]); // recurses into __proto__ and pollutes globally } else { target[key] = source[key]; } } return target;}// Safe reviver: strip dangerous keys before they ever reach a merge stepfunction safeReviver(key, value) { if (key === "__proto__" || key === "constructor" || key === "prototype") return undefined; return value;}JSON.parse(malicious, safeReviver); // '{}' -- dangerous key dropped during parsing
Deterministic (Canonical) JSON
Sort keys before stringifying so identical data always produces identical bytes -- required for hashing, diffing, and cache keys.
function canonicalStringify(value) { if (Array.isArray(value)) { return "[" + value.map(canonicalStringify).join(",") + "]"; } if (value !== null && typeof value === "object") { const keys = Object.keys(value).sort(); const body = keys.map(k => JSON.stringify(k) + ":" + canonicalStringify(value[k])); return "{" + body.join(",") + "}"; } return JSON.stringify(value);}canonicalStringify({ b: 2, a: 1 }); // '{"a":1,"b":2}'canonicalStringify({ a: 1, b: 2 }); // '{"a":1,"b":2}' -- same output regardless of insertion order// Regular JSON.stringify preserves insertion order for string keys, so two// logically-equal objects built in different orders hash differently unless canonicalized.
Streaming NDJSON with a Fetch Body
Parse newline-delimited JSON incrementally instead of buffering the whole response before calling JSON.parse.
async function* parseNDJSON(response) { const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let newlineIndex; while ((newlineIndex = buffer.indexOf("\n")) >= 0) { const line = buffer.slice(0, newlineIndex).trim(); buffer = buffer.slice(newlineIndex + 1); if (line) yield JSON.parse(line); } } if (buffer.trim()) yield JSON.parse(buffer); // trailing line with no final newline}// Usage: for await (const record of parseNDJSON(await fetch("/events.ndjson"))) { ... }
Advanced Reference
Lesser-known behaviors that surprise experienced developers.
- toJSON on Date- Date.prototype.toJSON() returns toISOString(); an Invalid Date serializes to 'null', not a thrown error
- JSON.stringify(fn, replacer, indent)- indent can be a string (e.g. '\t') instead of a number for custom whitespace
- Symbol keys- Both Symbol-keyed properties and Symbol values are silently omitted by JSON.stringify
- Array holes- Sparse array holes serialize as 'null' in the output, same as undefined elements
- toJSON precedence- If a value defines toJSON(), the replacer function receives the RESULT of toJSON(), not the original object
- JSON.parse reviver order- The reviver walks bottom-up: children are revived before their parent, so a parent's reviver call sees already-transformed children
- Number precision- Integers beyond Number.MAX_SAFE_INTEGER silently lose precision through JSON.parse; use a string or BigInt convention instead
Don't use JSON.parse(JSON.stringify(obj)) to deep-clone objects containing Dates, Maps, Sets, or undefined values -- they'll be silently corrupted or dropped; use structuredClone() instead, which handles all of these correctly.