JSON explained: the grammar, what it cannot represent, and how to use it safely
The complete JSON grammar on one page, the differences from JavaScript objects that cause invalid documents, what JSON.stringify silently drops, dates and big numbers, validating untrusted input, and when not to use JSON at all.
JSON (JavaScript Object Notation) is the format nearly every web API speaks, every config file uses, and every localStorage value is squeezed through. It is small enough to learn completely in one sitting, and worth doing, because most JSON bugs come from the handful of places where it differs from JavaScript. This tutorial covers the whole grammar, the conversion rules in JSON.stringify and JSON.parse, and the habits that keep untrusted JSON from becoming a security problem. The JSON formatter will tell you exactly where a document goes wrong.
The entire grammar
A JSON document is one value. A value is one of six things:
| Type | Example | Notes |
|---|---|---|
| object | {"name": "Ada", "age": 36} | Keys are strings in double quotes; order is not significant; duplicate keys are allowed by the grammar but the last one wins in practice. |
| array | [1, "two", null] | Ordered, mixed types allowed. |
| string | "line one\nline two" | Double quotes only. Escapes: \" \\ \/ \b \f \n \r \t \uXXXX. Any Unicode character may appear directly except ", \ and control characters. |
| number | -12.5e3 | Decimal only: no leading zeros, no hex, no NaN, no Infinity, no leading +, no trailing dot. |
| boolean | true / false | Lowercase. |
| null | null | Lowercase. |
Whitespace between tokens is ignored. That is the whole language; the formal spec is RFC 8259, and it fits on a few pages.
Not JavaScript
JSON looks like a JavaScript object literal and is deliberately a subset of one. What is allowed in JavaScript but not in JSON:
// Valid JavaScript, invalid JSON:
{
name: 'Ada', // unquoted key; single quotes
'tags': ["a", "b",], // trailing comma
count: 0x1F, // hex literal
when: new Date(), // any expression
note: undefined, // undefined is not a JSON value
// a comment
}The same data as JSON:
{
"name": "Ada",
"tags": ["a", "b"],
"count": 31,
"when": "2026-10-11T09:30:00Z",
"note": null
}Comments are the one people miss most. JSON has none, by design; configuration formats that need them use JSON5, JSONC (what VS Code's settings use) or YAML instead.
JSON.stringify: what gets dropped
Converting a JavaScript value to JSON is lossy in specific, documented ways:
JSON.stringify({ a: undefined, b: () => 1, c: Symbol('s') }) // '{}' dropped silently
JSON.stringify([undefined, () => 1]) // '[null,null]' arrays keep the slot
JSON.stringify({ d: new Date(0) }) // '{"d":"1970-01-01T00:00:00.000Z"}' via toJSON
JSON.stringify({ n: NaN, i: Infinity }) // '{"n":null,"i":null}'
JSON.stringify(new Map([['k', 1]])) // '{}' Maps and Sets have no JSON form
JSON.stringify({ big: 10n }) // TypeError: BigInt not serializable
JSON.stringify({ self: obj }) where obj.self === obj // TypeError: circular structureTwo optional arguments help. The second, a replacer, is an array of keys to keep or a function called for every key/value; the third is the indentation for human-readable output:
JSON.stringify(user, ['name', 'email'], 2); // only those keys, pretty-printed
JSON.stringify(data, (key, value) => typeof value === 'bigint' ? value.toString() : value);An object with a toJSON() method controls its own output, which is how Date becomes an ISO string. Because JSON has no date type, the convention is exactly that: ISO 8601 strings in UTC, parsed back with new Date(string).
JSON.parse: what comes back
JSON.parse returns plain objects, arrays, strings, numbers, booleans and null. Dates come back as strings; class instances come back as plain objects with no methods. Numbers are parsed as JavaScript doubles, so an integer above 253 (9,007,199,254,740,992), such as a 64-bit database ID, is silently rounded. APIs that have such IDs send them as strings for this reason; if you control the API, do the same. The optional reviver function runs for every value and can rebuild dates:
const data = JSON.parse(text, (key, value) =>
typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value) ? new Date(value) : value
);Parsing fails with a SyntaxError whose message names the position. Always catch it when the text came from outside your program: a network response, storage, a file, a query parameter.
Validating untrusted JSON
Valid JSON is not the same as the data you expected. After parsing, check the shape before using it, or a missing field becomes a TypeError somewhere far from the cause, and a malicious field becomes a security hole:
function parseTask(text) {
let v;
try { v = JSON.parse(text); } catch { return null; }
if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
if (typeof v.text !== 'string' || v.text.length > 500) return null;
if (typeof v.done !== 'boolean') return null;
return { text: v.text, done: v.done }; // copy only the known fields
}Copying known fields into a fresh object, rather than using the parsed object directly, drops anything unexpected. One specific hazard: a key named __proto__ in parsed JSON is just a normal own property (JSON.parse does not treat it specially), but if you then merge that object into another with a naive deep-merge, it can overwrite Object.prototype, a bug class called prototype pollution. Validate, copy fields, and do not deep-merge untrusted input. For larger schemas, a validator library such as Ajv (JSON Schema) or Zod expresses these rules declaratively.
Sending and receiving JSON
// browser -> server
await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(task),
});
// server -> browser (Node, no framework)
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify(tasks));The content type matters in both directions: servers reject or ignore bodies without it, and browsers' response.json() works regardless of the header, but tooling and caches behave better when it is right. JSON is always UTF-8 on the wire.
When not to use JSON
- Binary data. Base64 inside JSON costs 33% more bytes and parse time. Send files as files (multipart or a direct upload).
- Very large datasets.
JSON.parseneeds the whole text in memory. Stream newline-delimited JSON (one object per line) or use a database. - Human-edited configuration with comments. JSONC, TOML or YAML.
- Exact decimals. Money in JSON as
0.1becomes a double. Send an integer number of cents, or a string.
Pitfalls
- Trailing commas, single quotes, comments: the three most common invalid-JSON causes.
undefinedvanishes from objects and becomesnullin arrays.- Dates and large integers survive only as strings.
- A byte-order mark at the start of a file (some Windows editors add one) makes
JSON.parsefail on what looks like valid text. - Using
JSON.parse(JSON.stringify(x))as a deep clone drops dates, Maps, functions and undefined; usestructuredClone(x).
Exercise
Write a parseSettings(text) function for a settings object with a theme ("light" or "dark"), a fontSize (number 12 to 24) and an optional lastOpened ISO date. It should return a fully populated object with defaults for anything missing or invalid, never throw, and never keep unknown keys. Test it with the invalid documents from this page in the formatter first to see the error positions.
Summary
- Six value types, double-quoted keys and strings, no comments, no trailing commas.
stringifydrops undefined, functions and symbols, and throws on BigInt and cycles;toJSONand the replacer customise it.parsegives plain data; dates and 64-bit integers must be strings; always catch theSyntaxError.- Validate the shape and copy known fields before trusting parsed input.