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.
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
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:
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 object | Appears in the JSON as | Notes |
|---|---|---|
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, Infinity | null | Silent: no error |
-0 | 0 | The 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", false | Wrapper objects are unwrapped |
10n (BigInt) | throws TypeError: Do not know how to serialize a BigInt | The 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:
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:
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:
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:
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 ].
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:
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:
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:
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.)
| 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 page | throws Unexpected token '<', "<html><bod"... is not valid JSON | throws HTTP 502: <html><body>Bad Gateway</body></html> |
| /empty — 204, no body | throws Unexpected end of JSON input | null |
| /badjson — 200, text cut off | throws Expected double-quoted property name in JSON at position 8 | throws 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:
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:
| JSON round-trip | structuredClone | |
|---|---|---|
| Date | becomes a string | stays a Date |
undefined, NaN | key dropped, null | kept |
| Map, Set | become {} | kept |
| circular references | throws | kept (I checked the copy's self pointed into the copy) |
| functions | dropped | throws DOMException: f() {} could not be cloned. |
| class instances | own data only | own data only, as a plain object (the prototype and methods are not copied) |
| purpose | produces text you can send or save | produces 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" 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.
"[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
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.
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.
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.
Chapter 2 Quick Reference
JSON.parse(text, reviver)throwsSyntaxErroron bad text;JSON.stringify(value, replacer, indent)returnsundefinedfor a top-levelundefined, 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/Infinitybecomenull;-0becomes0; Map, Set, RegExp, Error become{}; arrays turnundefinedintonull. 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
"", returnundefinedto drop a key;thisis the holder - Reviver is called inside-out, root last; returning
undefineddeletes; a key-name rule matches at every depth; third argumentcontext.sourcegives 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; checktypeof JSON.rawJSONfirst- Circular data throws
Converting circular structure to JSON; a replacer must test ancestors, not every object seen, or shared objects are wrongly replaced fetchresolves on 404 and 502: checkres.ok;.json()ignoresContent-Type; a 204 gives "Unexpected end of JSON input"; "Unexpected token <" means HTML arrived; a body can be read once;fetchneedsContent-Type: application/jsonand 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
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.