Modelling real data without a browser · Lesson 55 of 77 · concept
JSON: turning data into text and back
About 60 minutes.
By the end of this lesson you can
- Say what serialisation is and why data has to become text to leave a program.
- Convert a value to JSON text with JSON.stringify and back with JSON.parse.
- Name three JavaScript values JSON cannot carry, and say what happens to each.
- Handle text that is not valid JSON without the program falling over.
Why data has to flatten into text
Explanation
An object inside a running program is not a thing you can post. It exists as a pattern in memory that only that program, running right now, knows how to read. The moment you want to save it to a file, send it to a server, or hand it to a program written in a different language, it has to become something universally readable — and the most universally readable thing there is, is text.
SERIALISATION is turning a value into text. The reverse, turning that text back into a value, is called parsing or deserialising. JSON is one particular set of rules for doing both, and it is the one you will meet almost everywhere: configuration files, web APIs, browser storage, log lines.
JSON stands for JavaScript Object Notation and its rules were taken from JavaScript, which is why the text looks so familiar. Do not let that fool you into thinking it is JavaScript. It is a much smaller language, it has no functions and no variables, and there are ordinary JavaScript values it simply cannot express.
The two functions, and what they hand back
Explanation
JSON.stringify(value) takes a value and returns a STRING. Not an object that looks like text — an actual piece of text, which you can check with typeof.
JSON.parse(text) takes a string and returns a fresh VALUE built from it. Fresh is the important word: the object you get back is brand new. It is not the object you started with, even if it holds exactly the same facts, so comparing them with === gives false.
That freshness is quietly useful. Round-tripping a value through JSON.stringify and then JSON.parse gives you a deep copy: a copy where the nested objects are new too, not shared with the original. It is the cheapest deep copy in the language and, for plain data, it is a perfectly reasonable one. Lesson 5 explains why deep matters.
JSON.stringify also takes a third argument for indentation. JSON.stringify(value, null, 2) produces the same data with newlines and two-space indents, which is what makes a saved configuration file readable by a human. The second argument, which we are skipping, filters or transforms properties.
The values JSON cannot carry
A common mistake
JSON has exactly six kinds of value: objects, arrays, strings, numbers, true/false, and null. Anything else you hand it has to be dealt with somehow, and the rules are worth knowing before they surprise you.
undefined, functions and symbols are OMITTED when they are the value of an object property — the property simply is not there in the output. Inside an array they cannot be omitted, because that would shift every later position, so they become null instead. And if you call JSON.stringify on one of them directly, you get back undefined rather than a string.
NaN and Infinity become null, because JSON has no way to write them. This one bites: a calculation that went wrong produces NaN, which serialises to null, which parses back as null, and now a totally different part of the system is reporting a missing value rather than a broken sum.
A Date is not dropped, but it does not survive either. Dates serialise to an ISO 8601 string such as 1970-01-01T00:00:00.000Z, and JSON.parse has no way of knowing that string was ever a date, so it comes back as text. If a date has to survive a round trip, you turn it back into a Date yourself on the way in.
A BigInt is the one that throws rather than degrading: JSON.stringify raises a TypeError instead of guessing. Map and Set do not throw, but they serialise to {} because neither keeps its contents in ordinary properties. Lesson 4 comes back to that.
Parsing is the step that fails
Why it matters
JSON.stringify is given a value your own program built, so it almost always succeeds. JSON.parse is given text from somewhere else — a file someone edited, a server having a bad day, a value read from browser storage that an older version of your code wrote. Text from elsewhere is text you did not check.
When the text is not valid JSON, JSON.parse throws a SyntaxError. An empty string throws. A trailing comma throws. Property names in single quotes, or with no quotes at all, throw — JSON is much stricter than JavaScript here, and this is the single most common surprise.
So every JSON.parse of outside text belongs inside a try/catch, with a decision written down for what happens when it fails: fall back to defaults, ask again, or report it. You met try/catch in the errors module; this is its most common real use.
Recap
Recap
Serialisation turns a value into text so it can leave the program; JSON is the near-universal set of rules for doing it. JSON.stringify returns a string, JSON.parse returns a brand new value, and the round trip is a cheap deep copy for plain data. undefined and functions vanish from objects and become null in arrays, NaN and Infinity become null, Dates become strings, BigInt throws. JSON.parse throws a SyntaxError on anything invalid, so wrap it.
Worked examples
Every output below was produced by actually running the code in the same sandbox your exercises run in — it is not a prediction.
Out to text and back again
The whole cycle in six lines. Watch the type change to string and back, and notice that what comes back is a different object from the one that went out.
1const settings = { theme: "dark", fontSize: 16, tags: ["a", "b"] };2 3const text = JSON.stringify(settings);4console.log(text);5console.log(typeof text);6 7const back = JSON.parse(text);8console.log(back.fontSize + 1);9console.log(back === settings);10console.log(back.tags === settings.tags);- line 3
One value in, one string out. Property names are always wrapped in double quotes in JSON, even though you did not write them that way.
- line 5
Proof that it really is text: typeof says "string". You could now write this to a file or send it over a network.
- line 7
Parsing rebuilds a value from the text. fontSize is a real number again, which is why adding 1 to it works on the next line.
- line 9
false, because === on objects asks whether these are the SAME object, not whether they hold the same facts. Parsing always builds a new one.
- line 10
false too, and this is the deep part: even the nested tags array is new. Nothing at any level is shared with the original.
What it prints
{"theme":"dark","fontSize":16,"tags":["a","b"]}string17falsefalse
What JSON does with values it cannot express
Four different disappearing acts in four lines. Read the output next to the input and note that none of these raised an error — the data quietly changed shape.
1const record = { id: 7, name: "Ada", note: undefined, score: NaN };2console.log(JSON.stringify(record));3console.log(JSON.stringify([1, undefined, 3]));4console.log(JSON.stringify("just text"));5console.log(String(JSON.stringify(undefined)));- line 2
The note property is gone entirely, because undefined is omitted inside an object. score survives as null, because NaN has no JSON spelling.
- line 3
Inside an array, undefined becomes null instead of vanishing — dropping it would shift 3 from position 2 to position 1 and change the meaning of the list.
- line 4
A string is valid JSON on its own, so you get text containing quotation marks. That is why the output has quotes around it and the earlier ones did not.
- line 5
JSON.stringify(undefined) returns the value undefined, not a string. String(...) is here only so console.log has something printable to show.
What it prints
{"id":7,"name":"Ada","score":null}[1,null,3]"just text"undefined
Readable output, and text that will not parse
The third argument makes saved data human-readable. The try/catch shows the failure you must plan for, using text that looks close enough to JSON to be tempting.
1const point = { x: 1, y: 2 };2console.log(JSON.stringify(point, null, 2));3 4try {5 JSON.parse("{x: 1}");6} catch (error) {7 console.log(error.name);8}9 10try {11 JSON.parse("");12} catch (error) {13 console.log("empty text also fails: " + error.name);14}- line 2
The 2 is the number of spaces to indent by. The null in the middle is the filter argument, which we are not using. The result is one string containing newlines.
- line 5
This is how you would write the object in JavaScript source, but JSON is stricter: it requires every property name to be wrapped in double quotes. The most common reason hand-written JSON fails.
- line 7
Every JSON.parse failure is a SyntaxError. Reading error.name is enough to tell this apart from other failures in the same try block.
- line 11
An empty string is not valid JSON either. It is worth testing for deliberately, because an unset value read from storage is very often exactly this.
What it prints
{ "x": 1, "y": 2 }SyntaxErrorempty text also fails: SyntaxError
Check yourself
Not scored, not stored. Getting one wrong is the useful part.
What does JSON.stringify({ a: undefined, b: 1, c: NaN }) produce?
A value is round-tripped through JSON and then the copy's nested array is changed. What do the two lines print?
const original = { list: [1, 2] };const copy = JSON.parse(JSON.stringify(original));copy.list.push(3);console.log(JSON.stringify(original));console.log(JSON.stringify(copy));
An object holding a Date is passed through JSON.stringify and then JSON.parse. What is in the date property afterwards?
Write it yourself
Exercise · core · 7 tests
Read settings that might be broken
A settings file arrives as text. It might be perfectly good JSON, it might be an empty string, it might be something a person edited by hand and broke, and it might be valid JSON that is not a settings object at all — "null", or an array. Write parseSettings(text) that returns the settings object when the text really is one, and otherwise returns the defaults { theme: "light", fontSize: 14 }. It must never throw.
Returns the parsed value when the text parses to a plain object. Returns a fresh defaults object { theme: "light", fontSize: 14 } for invalid text, for "null", and for a JSON array. Fresh means every fallback call returns its own object, so changing one result never affects the next. It never throws, whatever it is given. The tests read the returned value, not anything you printed.
Define parseSettings at the top level of your code — the tests call it by name.
Worth remembering
- What do JSON.stringify and JSON.parse each hand back?
stringify returns a string. parse returns a brand new value built from that string — never the object you started with, which is why the round trip works as a deep copy for plain data.
- What does JSON do with undefined, NaN and a Date?
undefined is omitted from an object and becomes null inside an array. NaN and Infinity become null. A Date becomes an ISO 8601 string and comes back as text, not as a Date.
- What happens when JSON.parse is given text that is not valid JSON?
It throws a SyntaxError — including for an empty string. Any parse of text from outside your program belongs in a try/catch with a decided fallback.
Check this lesson against the source
We wrote the explanation; we did not invent the facts. This is the page that backs them.
Check there: The Description section: undefined, functions and symbols are omitted inside objects and become null inside arrays; NaN and Infinity become null; BigInt throws a TypeError; the third argument sets the indentation.
Check there: That JSON.parse throws a SyntaxError when the text is not valid JSON.
Check there: That a Date is serialised as an ISO 8601 string, which is why JSON.parse hands it back as text rather than as a Date.
Prefer to read the source in a structured breakdown? Turn this doc into a breakdown