What JSON Is, and What It Is Not
JSON stands for JavaScript Object Notation. It is a text format for structured data, and it is what almost every API on the internet uses to send and receive information. Learning the format takes ten minutes. The corners where it differs from JavaScript take a little longer, and they are where all the bugs live.
It looks like a JavaScript object literal because it was derived from one, but it is considerably stricter. Keys must be wrapped in double quotes. Strings must use double quotes — single quotes are invalid JSON, however normal they look. No trailing commas. No comments of any kind. And only six kinds of value are allowed: string, number, boolean, null, array and object.
The thing to keep absolutely straight is that JSON is text. A JSON string is not an object; it is a string that describes one. Two functions cross that boundary: JSON.stringify turns a JavaScript value into JSON text, and JSON.parse turns JSON text back into a JavaScript value. Nearly every JSON bug is a confusion about which side of that line you are currently standing on.
You can settle that question in one line with typeof. If typeof data is 'string', you are holding text and need to parse it. If it is 'object', you already have data, and calling JSON.parse on it will fail with a message about an unexpected token that tells you nothing useful about the real mistake.
// Valid JSON text - note the double quotes everywhere
const text = '{"name": "Ananya", "marks": 82, "passed": true, "grade": null}';
console.log(typeof text); // 'string' - it is text
const data = JSON.parse(text);
console.log(typeof data); // 'object' - now it is data
console.log(data.name); // 'Ananya'
// Each of these is invalid JSON, for a different reason
// "{'name': 'Ananya'}" - single quotes
// '{name: "Ananya"}' - unquoted key
// '{"a": 1,}' - trailing comma
// '{"a": 1 /* note */ }' - comments are not allowed
// And back to text again
console.log(JSON.stringify(data));
// {"name":"Ananya","marks":82,"passed":true,"grade":null} - JSON is not JavaScript, despite the name. It is a language-independent format, and Python, Java, PHP and every other language read and write it. That is exactly why APIs use it.
JSON.stringify
JSON.stringify(value) converts a JavaScript value into JSON text. That is what you do before sending data to a server and before storing it in localStorage, since both of those only accept strings.
It takes two further arguments that are genuinely useful and rarely taught. The third is an indentation setting: JSON.stringify(data, null, 2) produces readable, indented output, which is what you want when writing a configuration file or printing something a human will read. The second is a replacer — either an array of keys to keep, or a function that transforms each value on the way out.
The replacer array is the quick way to send only part of an object: JSON.stringify(user, ['name', 'email']) produces exactly those two fields. The replacer function is how you strip something sensitive at every level at once — returning undefined from it for a key drops that key, wherever in the structure it appears.
An object can also control its own serialisation by defining a toJSON() method, which stringify calls and then uses the result of. This is not obscure trivia: it is exactly how a Date turns itself into an ISO string, and it is why dates survive stringification in a recognisable form while other objects do not.
const student = {
name: 'Ananya',
marks: 82,
subjects: ['Maths', 'Physics'],
password: 'secret123'
};
console.log(JSON.stringify(student));
// {"name":"Ananya","marks":82,"subjects":["Maths","Physics"],"password":"secret123"}
// Readable, indented output
console.log(JSON.stringify(student, null, 2));
// An allow-list of keys
console.log(JSON.stringify(student, ['name', 'marks']));
// {"name":"Ananya","marks":82}
// A replacer function - drop a field wherever it appears
console.log(JSON.stringify(student, (key, value) => {
return key === 'password' ? undefined : value;
}));
// An object deciding how it serialises itself
const order = {
id: 7,
total: 500,
toJSON() {
return { orderId: this.id };
}
};
console.log(JSON.stringify(order)); // {"orderId":7} JSON.stringify(value, null, 2)is the fastest way to look at a deeply nested object in a log.console.logoften collapses or truncates nested structures; the stringified version shows everything.
JSON.parse, and Parsing Safely
JSON.parse(text) converts JSON text into a JavaScript value. It throws a SyntaxError the instant the text is not valid JSON, and that throw is among the most common errors in this part of the language.
Because it throws, any parse of data you did not produce yourself belongs inside a try/catch. There are three usual sources of invalid text: an HTML error page returned where JSON was expected — the Unexpected token '<' case from the previous lesson — an empty string, and something a user pasted into a field.
One difference matters when reading from storage. JSON.parse(null) returns null without complaint, because null is converted to the text "null" first, whereas JSON.parse('') throws. A missing storage key gives you null and an empty one gives you '', so the two paths behave differently. Wrapping the parse in a helper that returns a default on any failure removes the whole class of problem in one place.
JSON.parse also takes a second argument, a reviver function called for every key and value on the way in. It is how you turn ISO date strings back into real Date objects — a job nothing else will do for you, because JSON has no date type at all and your dates arrive as ordinary strings.
// Safe by default
function parseJson(text, fallback = null) {
try {
return JSON.parse(text);
} catch {
return fallback;
}
}
console.log(parseJson('{"a":1}')); // { a: 1 }
console.log(parseJson('not json', {})); // {}
console.log(parseJson('', [])); // []
// The difference that bites when reading storage
console.log(JSON.parse(null)); // null - does not throw
// console.log(JSON.parse('')); // SyntaxError
// A reviver rebuilds Date objects on the way in
const text = '{"name":"Exam","when":"2026-08-15T09:00:00.000Z"}';
const event = JSON.parse(text, (key, value) => {
if (key === 'when') return new Date(value);
return value;
});
console.log(event.when instanceof Date); // true
console.log(event.when.getFullYear()); // 2026 - The error message from
JSON.parseincludes the position where parsing failed. On a long string that number is genuinely useful — slice a few characters either side of it and the problem is usually obvious immediately.
What JSON Cannot Represent
JSON has six types. JavaScript has considerably more. Anything that does not fit is either dropped or converted, silently and without warning, and knowing the list in advance saves a great deal of confusion later.
Dropped: undefined, functions and symbols. In an object, the entire property disappears — the key is not there at all afterwards. In an array, the same values become null instead, because an array cannot lose an element without changing its length and its indexes. Two different behaviours from one operation, decided by where the value happened to sit.
Converted: a Date becomes an ISO string through its toJSON method, and comes back as a string unless you supply a reviver. NaN and Infinity both become null, which is a silent loss of meaning — a failed calculation and a missing value end up indistinguishable. A Map or a Set becomes {}, because stringify only sees own enumerable properties and those two hold their contents internally.
Throws: a BigInt raises a TypeError, and so does a circular reference — an object that directly or indirectly contains itself. Circular references are common in DOM objects and in linked data structures, which is why JSON.stringify(someElement) throws rather than printing the element.
const data = {
name: 'Ananya',
nothing: undefined, // dropped entirely
greet: function () {}, // dropped entirely
when: new Date(2026, 7, 15), // becomes an ISO string
broken: NaN, // becomes null
huge: Infinity, // becomes null
tags: new Set(['a', 'b']) // becomes {}
};
console.log(JSON.stringify(data));
// {"name":"Ananya","when":"...","broken":null,"huge":null,"tags":{}}
// In an ARRAY the same values become null instead of vanishing
console.log(JSON.stringify([1, undefined, function () {}, 4]));
// [1,null,null,4]
// Circular references throw
const a = { name: 'a' };
a.self = a;
// JSON.stringify(a);
// TypeError: Converting circular structure to JSON
// So does a BigInt
// JSON.stringify({ n: 10n });
// TypeError: Do not know how to serialize a BigInt
console.log(JSON.stringify({ n: String(10n) })); // {"n":"10"} - Very large integers are the practical version of the BigInt problem. An id beyond
Number.MAX_SAFE_INTEGERloses precision the momentJSON.parseturns it into a number, which is why well-designed APIs send such ids as strings.
JSON in Practice
There are three places you will meet JSON constantly, and each has its own habit worth forming.
APIs. response.json() parses for you, and JSON.stringify together with the Content-Type header prepares what you send — both covered in the previous lesson. What is worth adding here is a discipline: never assume the shape of what comes back. A field you expect may be missing, renamed, or null. Read nested values with ?. and supply defaults with ??, and normalise the response into the shape your code wants at the boundary, so the rest of your program never deals with surprises.
Storage. localStorage only stores strings, so every object goes in through stringify and comes back through parse. Lesson 25 covers this properly. The point to carry forward now is that stored JSON was written by an older version of your own code, possibly months ago, so it can easily be in a shape your current code no longer expects. Parse it defensively and merge it over a set of defaults.
Configuration and data files. JSON's lack of comments is genuinely painful here, which is why package.json carries no explanatory notes and why many tools accept JSON5 or YAML instead. Within plain JSON the usual workaround is a "_comment" key — ugly, and valid.
// API: normalise at the boundary, so nothing downstream has to guess
async function loadStudent(id) {
const response = await fetch(`/api/students/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
return {
name: data.name ?? 'Unknown',
marks: Number(data.marks ?? 0),
city: data.address?.city ?? 'Not set'
};
}
// Storage: stringify going in, parse coming out, defensively
function saveSettings(settings) {
localStorage.setItem('settings', JSON.stringify(settings));
}
const DEFAULT_SETTINGS = { theme: 'light', fontSize: 14 };
function loadSettings() {
const raw = localStorage.getItem('settings'); // null when never set
if (!raw) return { ...DEFAULT_SETTINGS };
try {
// defaults first, stored values on top - so a new setting still has a value
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
} catch {
return { ...DEFAULT_SETTINGS };
}
} - Spreading defaults first and stored values second is what lets you add a new setting without breaking every existing user. Anyone whose stored object predates the new key simply gets the default for it.
Deep Copy, and the Limits of the JSON Trick
You will see JSON.parse(JSON.stringify(obj)) recommended everywhere as a way to deep-copy an object. It does work, and it works for exactly the data JSON can represent — which means it quietly damages everything in the previous section's list.
Put a student record containing a Date through it and the date comes back as a string, so the getFullYear() call three files away throws with no obvious connection to the copy. Put one with an undefined field through it and the field vanishes, so a later 'field' in obj check changes its answer. Put a circular structure through it and it throws outright. None of these announce themselves at the point where the copy was made.
structuredClone(obj) is the right tool for the job. It is built into modern browsers and into Node 17 and later, it handles dates, maps, sets and circular references correctly, and it is a single call with no round trip through text. It cannot copy functions — nothing can — and it throws if you hand it one, which is better than silently dropping it.
So: use the JSON round trip only when you know the data is plain, such as an object that came straight from an API and holds nothing but strings, numbers and arrays. Prefer structuredClone everywhere else. And where you only need one level of copying, the spread operator from Lesson 5 is cheaper and clearer than both.
const original = {
name: 'Ananya',
when: new Date(2026, 7, 15),
optional: undefined,
marks: { maths: 82 }
};
// The JSON trick: a real deep copy, with real losses
const jsonCopy = JSON.parse(JSON.stringify(original));
jsonCopy.marks.maths = 95;
console.log(original.marks.maths); // 82 - genuinely independent
console.log(typeof jsonCopy.when); // 'string' - no longer a Date
console.log('optional' in jsonCopy); // false - the key is gone
// structuredClone keeps the types intact
const clone = structuredClone(original);
console.log(clone.when instanceof Date); // true
clone.marks.maths = 40;
console.log(original.marks.maths); // 82
// Only need one level? Spread is enough, and cheaper than both
const shallow = { ...original }; - JSON is text; a JavaScript object is data —
stringifyandparsecross that line - Double quotes everywhere, no trailing commas, no comments
JSON.parsethrows on bad input — wrap it intry/catchundefinedand functions are dropped from objects, and becomenullinside arrays- Dates become strings; use a reviver to turn them back
NaNandInfinitybecomenull;BigIntthrows- Circular references throw — including most DOM objects
JSON.stringify(data, null, 2)for output a human has to readstructuredClone, not the JSON round trip, for deep copies
- A quick self-test for this lesson: what does
JSON.stringify({ a: undefined, b: [undefined] })produce? The answer is{"b":[null]}— the object key disappears and the array element becomes null. If that makes sense to you, the section above did its job.
