Foundations

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.

By the TechTutorZone editorial team · Published · 6 min read (1,188 words)

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:

TypeExampleNotes
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.5e3Decimal only: no leading zeros, no hex, no NaN, no Infinity, no leading +, no trailing dot.
booleantrue / falseLowercase.
nullnullLowercase.

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 structure

Two 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.parse needs 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.1 becomes a double. Send an integer number of cents, or a string.

Pitfalls

  • Trailing commas, single quotes, comments: the three most common invalid-JSON causes.
  • undefined vanishes from objects and becomes null in 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.parse fail on what looks like valid text.
  • Using JSON.parse(JSON.stringify(x)) as a deep clone drops dates, Maps, functions and undefined; use structuredClone(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.
  • stringify drops undefined, functions and symbols, and throws on BigInt and cycles; toJSON and the replacer customise it.
  • parse gives plain data; dates and 64-bit integers must be strings; always catch the SyntaxError.
  • Validate the shape and copy known fields before trusting parsed input.