JSON in JavaScript

Working with JSON

Chapter 2 ยท Parsing and Generating in JavaScript

JavaScript has exactly two built-in JSON functions: JSON.parse turns text into values, and JSON.stringify turns values into text. Both take optional arguments that most people never use, and stringify quietly changes or drops things it cannot represent. This chapter covers every argument, lists exactly what is lost (as measured, not remembered), and then moves to the places JSON meets JavaScript in real programs: fetch, copying objects, and circular data. It assumes the six types and the rules from Working with JSON 1; basic JavaScript syntax comes from JavaScript Fundamentals.

Every example here was run, except where marked
Node 24.18.0 on Windows 11. The fetch examples run against small HTTP servers started inside the same script, on 127.0.0.1 (a random port each time, so port numbers in the output differ from yours). The two newest features (JSON.rawJSON and the reviver's context argument) were also checked in headless Chrome 154, where both exist. Not run: Firefox, Safari, older Node versions, and any real remote API. Check MDN's compatibility tables before relying on those two features in a browser you support.

The Two Functions

const value = JSON.parse(text, reviver); // text -> value (reviver optional) const text = JSON.stringify(value, replacer, indent); // value -> text (replacer, indent optional)

JSON.parse throws a SyntaxError for bad text, so any text that came from outside your program belongs in a try block. JSON.stringify returns a string, except that it returns undefined (not a string, and not "undefined") when the top-level value is undefined, a function or a symbol. I checked all three: JSON.stringify(undefined), JSON.stringify(() => 1) and JSON.stringify(Symbol('x')) each returned undefined. Code that does .length on the result of a stringify of an unknown value can crash on that.

Pretty-Printing with the Third Argument

The third argument, indent, is a number of spaces or a string to indent with. Without it you get the compact form. I printed one object four ways:

const o = { a: 1, b: [1, 2], c: { d: null } }; console.log(JSON.stringify(o)); console.log(JSON.stringify(o, null, 2)); console.log(JSON.stringify(o, null, '--'));
{"a":1,"b":[1,2],"c":{"d":null}} { "a": 1, "b": [ 1, 2 ], "c": { "d": null } } { --"a": 1, --"b": [ (first lines of the third form)

Three details I measured. The second argument is null when you want the indent but no replacer. A number above 10 is capped (20 produced 10 spaces), and a string indent is cut to its first 10 characters. Empty containers stay on one line: JSON.stringify([], null, 2) gave [], and a nested empty array printed as [] on its own line. Use compact output for network traffic and pretty output for files people read.

What stringify Drops or Changes

I serialised one object holding most of the awkward values in JavaScript and read the result back:

Value in the objectAppears in the JSON asNotes
undefined (property)(the key is omitted)But in an array it becomes null
a function / method (property)(omitted)In an array: null
Symbol('s') (property)(omitted)In an array: null
NaN, InfinitynullSilent: no error
-00The sign is lost
new Date(...)"2020-01-02T03:04:05.000Z"Through its toJSON; an invalid date gives null
/x/g (RegExp), new Error('boom'){}Nothing of their content is kept
new Map([[1,2]]), new Set([1]){}Empty object; the data is gone
new Uint8Array([1,2]){"0":1,"1":2}An object with index keys, not an array
new Number(5), new String('s'), new Boolean(false)5, "s", falseWrapper objects are unwrapped
10n (BigInt)throws TypeError: Do not know how to serialize a BigIntThe one value that fails loudly

The pattern to remember: most things are lost silently, and the program carries on with less data. The array rule matters too: dropping an undefined from an array would shift the other items, so stringify writes null in its place (my array [undefined, function(){}, Symbol('q'), NaN] came out as [null,null,null,null]).

Controlling Output with toJSON

If a value has a method named toJSON, stringify calls it and serialises what it returns. That is how Date works (its toJSON returns the ISO string), and you can give your own classes the same behaviour. The method receives the key the value was found under:

class Money { constructor(cents) { this.cents = cents; } toJSON(key) { return { amount: (this.cents / 100).toFixed(2), key }; } } console.log(JSON.stringify({ price: new Money(1999), list: [new Money(5)] }));
{"price":{"amount":"19.99","key":"price"},"list":[{"amount":"0.05","key":"0"}]}

Note that the key for an array item is its index as a string ("0"). toJSON runs before the replacer, so the replacer sees the already-converted value (for a date, the string, not a Date), which matters in the next section.

The Replacer: an Array or a Function

The second argument filters or transforms. As an array it is a whitelist of property names. I tried it on an object with a password at two levels:

const user = { id: 1, name: 'Ada', password: 'x', profile: { name: 'inner', age: 36, password: 'y' } }; JSON.stringify(user, ['id', 'name', 'profile']); JSON.stringify(user, ['id', 'name', 'profile', 'age']);
{"id":1,"name":"Ada","profile":{"name":"inner"}} {"id":1,"name":"Ada","profile":{"name":"inner","age":36}}

The whitelist applies at every depth: age inside profile disappeared until I added it to the list. That makes the array form easy to get wrong with nested data, and a whitelist can be wrong in the dangerous direction if you forget a key, because the omission is silent.

As a function, the replacer is called for every key and value, starting with the whole value under the key "" (an empty string). Return the value to keep it, return something else to replace it, or return undefined to drop the property. Inside it, this is the object or array that holds the key. For { a: [1, { b: 2 }] } I logged the calls:

JSON.stringify(user, (key, value) => key === 'password' ? undefined : value); JSON.stringify({ n: 1, s: 'x', big: 5n }, (key, value) => typeof value === 'bigint' ? value.toString() : value);
{"id":1,"name":"Ada","profile":{"name":"inner","age":36}} {"n":1,"s":"x","big":"5"} calls for { a: [1, { b: 2 }] }, in order: "" this=object; "a" this=object; "0" this=array; "1" this=array; "b" this=object

The function form removed password at both levels without listing the keys to keep, and it fixed the BigInt that would have thrown. It runs from the outside in: the whole value first, then each property. A function replacer is the right tool for "remove secrets" (a blacklist), and the array is right for "send only these fields" when the shape is flat.

The Reviver: Rebuilding Types on the Way In

JSON.parse only ever produces objects, arrays, strings, numbers, booleans and null. There is no way for JSON to say "this string is a date". The second argument, the reviver, is a function called for each value after parsing, so you can convert. I logged the order of the calls:

const text = '{"name":"Ada","born":"1815-12-10T00:00:00.000Z","tags":["a","b"],"n":{"x":1}}'; const r = JSON.parse(text, (key, value) => key === 'born' ? new Date(value) : value); console.log(r.born instanceof Date, r.born.getUTCFullYear());
visit order: name born 0 1 tags x n (root) true 1815

The reviver works from the inside out: children before parents, and the whole value (key "") last, which is the opposite of the replacer. Returning undefined deletes the property: {"a":1,"b":2,"c":3} with b removed gave { a: 1, c: 3 }, and doing the same to index 1 of [1,2,3] left a hole, [ 1, <1 empty item>, 3 ].

A reviver that matches by key name matches at every depth
A rule such as key === 'id' converts every property called id, in every nested object (in my test, an order line's "A-77" became 77n). If only the top-level id should change, test the nesting too, or convert after parsing instead. A string that merely looks like a date (an order number such as "2026-10-12") will also be converted if you match by value, so match by key where you can.

Big numbers. Recall that JavaScript read 12345678901234567890 as 12345678901234567000. Recent engines pass the reviver a third argument, context, whose source holds the original text of a primitive value. On Node 24.18 and Chrome 154, context was {"source":"12345678901234567890"}, so the exact digits can be turned into a BigInt. The matching writer is JSON.rawJSON, which inserts a number's own digits into the output; JSON.isRawJSON tests for one:

const text = '{"id":12345678901234567890,"small":42}'; console.log(JSON.parse(text).id); console.log(JSON.parse(text, (k, v, ctx) => k === 'id' ? BigInt(ctx.source) : v).id); console.log(JSON.stringify({ id: JSON.rawJSON('12345678901234567890') }));
12345678901234567000 12345678901234567890n {"id":12345678901234567890}

rawJSON accepts only a primitive's JSON text: given an object, {"a":1}, it threw a SyntaxError, and a number with a leading space (" 1") also threw. On older runtimes these functions may not exist, so test typeof JSON.rawJSON === 'function' before relying on them (I only tested the two runtimes named above).

Circular References

JSON is a tree, and an object that contains itself, directly or through a chain, has no finite text. stringify detects this and throws:

const a = { name: 'a' }; a.self = a; try { JSON.stringify(a); } catch (e) { console.log(e.message); }
TypeError: Converting circular structure to JSON --> starting at object with constructor 'Object' --- property 'self' closes the circle

For a longer chain the message names each step (property 'child' -> object with constructor 'Object', then property 'parent' closes the circle), which is a good map of what to fix. An object that is merely shared (the same object used in two places) is not circular: { a: shared, b: shared } serialised fine, writing the content twice.

A common fix on the web is a replacer that remembers every object it has seen and returns "[Circular]" for repeats. That version is wrong: on the shared (not circular) example it replaced the second use with "[Circular]" and lost data. The correct test is "is this object one of my own ancestors?". I wrote that version, using the fact that this is the current holder, and tried it on the circle, on the shared object, and on a deeper structure that has both a shared object and a cycle back to an ancestor:

const circularOnly = () => { const path = []; // the chain of objects from the root to here return function (key, value) { while (path.length && path[path.length - 1] !== this) path.pop(); // climb back to this value's holder if (value && typeof value === 'object') { if (path.includes(value)) return '[Circular]'; path.push(value); } return value; }; };
circle {"name":"a","self":"[Circular]"} shared {"a":{"z":1},"b":{"z":1}} deeper structure {"list":[{"z":1},{"inner":{"z":1},"deep":{"again":{"z":1},"up":"[Circular]"}},{"back":"[Circular]"}],"name":"a"}

It gave the right answer on all three. That is three tests, not a proof, so use a maintained library if circular data is a regular part of your work. Often the better fix is in the data model: store an id instead of a back-reference.

fetch and response.json()

response.json() reads the body and runs JSON.parse on it, so everything above applies, and it adds new ways to fail. I started one small local server with six routes and called each route two ways: the shortcut everyone writes, (await fetch(url)).json(), and a careful helper. (How HTTP itself works is in API Types & Design; sending these requests from a terminal is in API Testing & Tooling.)

async function getJson(url) { const res = await fetch(url); const type = res.headers.get('content-type') ?? ''; const body = await res.text(); // read once, as text if (!res.ok) throw new Error('HTTP ' + res.status + ': ' + (type.includes('json') ? body : body.slice(0, 40))); if (res.status === 204 || body === '') return null; try { return JSON.parse(body); } catch (e) { throw new Error('bad JSON (' + e.message + '), starts with ' + JSON.stringify(body.slice(0, 12))); } }
Route and what it sends(await fetch(u)).json()getJson(u)
/ok — 200, JSON{"id":7,"name":"Ada"}{"id":7,"name":"Ada"}
/notfound — 404, JSON error body{"error":"no such user"} (looks like success)throws HTTP 404: {"error":"no such user"}
/html — 502, HTML pagethrows Unexpected token '<', "<html><bod"... is not valid JSONthrows HTTP 502: <html><body>Bad Gateway</body></html>
/empty — 204, no bodythrows Unexpected end of JSON inputnull
/badjson — 200, text cut offthrows Expected double-quoted property name in JSON at position 8throws bad JSON (...), starts with "{\"id\":7,"
/wrongtype — 200, text/plain but valid JSON{"id":8} (the content type is ignored){"id":8}

Three lessons from the table. fetch does not fail on 404 or 502: only a network failure rejects the promise, so you must test res.ok or the status yourself, and a 404 whose body is JSON sails straight through the shortcut. .json() ignores the Content-Type: it parsed a text/plain body without complaint, so the header is no safeguard. And the "unexpected token <" error means you received HTML, usually an error page, so the first characters of the body are the most useful thing to log. The helper reads the body as text once and parses it itself, which gives it the text to quote in an error. A body can only be read once: a second await r.json() on the same response threw TypeError: Body is unusable: Body has already been read.

Sending JSON has its own trap. I posted the same body to an echo server three ways:

fetch(url, { method: 'POST', body: JSON.stringify({ a: 1 }) }); fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ a: 1 }) }); fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: { a: 1 } });
no header -> server saw content-type text/plain;charset=UTF-8, body {"a":1} with header -> server saw content-type application/json, body {"a":1} object as body -> server saw content-type application/json, body [object Object]

Without the header, fetch labels a string body text/plain, and a server that checks the type may refuse it. Passing an object instead of a string sent the literal text [object Object]: fetch does not call JSON.stringify for you.

Copying: JSON Round-Trip or structuredClone

JSON.parse(JSON.stringify(x)) is a famous way to deep-copy an object, and it is a lossy one, for every reason in the table above. structuredClone(x) is built in to Node and browsers for exactly this job. I copied the same object both ways:

const src = { d: new Date(0), u: undefined, n: NaN, m: new Map([[1, 2]]), nested: { x: 1 } }; console.log(JSON.parse(JSON.stringify(src))); console.log(structuredClone(src));
{ d: '1970-01-01T00:00:00.000Z', n: null, m: {}, nested: { x: 1 } } { d: 1970-01-01T00:00:00.000Z, u: undefined, n: NaN, m: Map(1) { 1 => 2 }, nested: { x: 1 } }
JSON round-tripstructuredClone
Datebecomes a stringstays a Date
undefined, NaNkey dropped, nullkept
Map, Setbecome {}kept
circular referencesthrowskept (I checked the copy's self pointed into the copy)
functionsdroppedthrows DOMException: f() {} could not be cloned.
class instancesown data onlyown data only, as a plain object (the prototype and methods are not copied)
purposeproduces text you can send or saveproduces a live copy; it is not text

Use structuredClone to copy data inside a program and JSON only when you need text.

Key Order

JSON objects are "unordered" in the standard, but JavaScript objects do have an order, and stringify follows it. The rule I observed: keys that look like array indexes (non-negative integers) come first, in numeric order; all other keys follow in the order they were added. I serialised { b: 1, a: 2, 10: 'x', 2: 'y', '-1': 'neg', '01': 'z' } and got:

{"2":"y","10":"x","b":1,"a":2,"-1":"neg","01":"z"}

"2" and "10" were pulled to the front and sorted as numbers; "-1" and "01" (not canonical array indexes) stayed in insertion order. So do not rely on key order carrying meaning (put an ordered list in an array), and when you compare two JSON texts, parse them first rather than comparing strings.

One Safety Note: the __proto__ Key

JSON.parse treats "__proto__" as an ordinary key. Parsing {"__proto__":{"admin":true},"a":1} gave an object whose own keys were __proto__,a; the global ({}).admin was still undefined and so was p.admin. The danger comes later, when code merges such an object into another with a naive loop, and Working with JSON 11 demonstrates and fixes that.

Mistakes while preparing this chapter
1. I planned to teach the popular "seen objects" circular-reference replacer. When I tested it on an object that is merely shared, it replaced the second use with "[Circular]" and lost data. How to avoid it: test a helper on the case that looks similar but is different (shared versus circular), not only on the case it was written for. The ancestor-path version in this chapter passed both, and a third deeper test.

2. The outline said to check whether JSON.rawJSON exists and label it if not. I half-expected to write "not available yet". In fact Node 24.18 and Chrome 154 both have it and the reviver context argument. How to avoid it: run typeof on the feature in each runtime you name, rather than carrying over what you remember from a year ago; and say which runtimes you did not test, as above.

3. The reviver exercise first matched key === 'id'. That worked only because my sample had a single id. When I added an order line with "id": "A-77" and a customer "id": "42", the same rule turned them into 77n and 42n (the letters of A-77 silently stripped). How to avoid it: when a key-name rule is the whole logic, build one sample where the same key appears at a second depth and see what happens.

Hands-On Exercises

Exercise 1

Build an order object holding a BigInt id, a nested customer with a password, a Date, a Set of tags, an undefined field and a NaN. Show that a plain stringify throws, then write one replacer function that makes it serialise (BigInt as a string, Set as an array, password removed) and print the result with a 2-space indent. Parse it back with a reviver that restores the BigInt, the Date and the Set, and check each with a comparison. Finish with a control: parse the same text without the reviver and print the types you get.

๐Ÿ“„ View solution
Exercise 2

Start a local Node HTTP server with six routes: a 200 JSON body, a 404 with a JSON error body, a 502 with an HTML body, a 204 with no body, a 200 whose JSON is cut off, and a 200 with a text/plain type but valid JSON. Call each with (await fetch(url)).json() and with your own getJson helper, and print both results side by side. Then show that reading a body twice fails, and POST the same object three ways (no header, with header, body not stringified) to an echo route to show what the server received.

๐Ÿ“„ View solution
Exercise 3

Copy one object with a Date, undefined, NaN and a Map using a JSON round-trip and using structuredClone, and list what each kept. Then make a circular object and show the stringify error, write a circular-reference replacer, and test it on the circle and on a merely shared object (also test the naive "seen" version to see it fail). Finally show key order for a mixed object, and read a 20-digit id exactly with the reviver's context.source and write it with JSON.rawJSON.

๐Ÿ“„ View solution

Chapter 2 Quick Reference

  • JSON.parse(text, reviver) throws SyntaxError on bad text; JSON.stringify(value, replacer, indent) returns undefined for a top-level undefined, function or symbol
  • Indent: a number (capped at 10) or a string (first 10 characters); empty arrays and objects print as [] and {}
  • Silently lost: undefined, functions, symbols (as properties); NaN/Infinity become null; -0 becomes 0; Map, Set, RegExp, Error become {}; arrays turn undefined into null. A BigInt throws
  • toJSON(key) decides what an object serialises as (Date already has one); it runs before the replacer
  • Replacer array = whitelist at every depth; replacer function = called outside-in starting with key "", return undefined to drop a key; this is the holder
  • Reviver is called inside-out, root last; returning undefined deletes; a key-name rule matches at every depth; third argument context.source gives the original number text (Node 24.18, Chrome 154)
  • JSON.rawJSON('123...') writes exact digits; an object, or a number with a leading space, is rejected; check typeof JSON.rawJSON first
  • Circular data throws Converting circular structure to JSON; a replacer must test ancestors, not every object seen, or shared objects are wrongly replaced
  • fetch resolves on 404 and 502: check res.ok; .json() ignores Content-Type; a 204 gives "Unexpected end of JSON input"; "Unexpected token <" means HTML arrived; a body can be read once; fetch needs Content-Type: application/json and a stringified body
  • Copy data with structuredClone (keeps Date, Map, Set, undefined, NaN, cycles; throws on functions); use JSON only to make text. Integer-like keys are written first, in numeric order
Coming next
Chapter 3 is JSON in Python: json.loads, json.dumps, load and dump, the indent, sort_keys, separators and ensure_ascii arguments, how Python types map to JSON, default= and object_hook for dates, Decimal and dataclasses, the NaN and Infinity extension and allow_nan, and a comparison with this chapter. Start with Working with JSON 3.