JSON methods, toJSON

You have an object in memory. You want it somewhere else: written to a log, saved to disk, sent to a server across the network. Memory can’t travel, but text can. So the recurring problem is turning a live object into a string, and later rebuilding the object from that string.

You could hand-roll the conversion. Give the object a toString method that spells out its shape:

let user = {
  name: "Maya",
  age: 30,

  toString() {
    return `{name: "${this.name}", age: ${this.age}}`;
  }
};

alert(user); // {name: "Maya", age: 30}

That works until the object changes. Add a field, rename one, remove another, and you have to hand-edit toString to match. Now imagine the object holds nested objects, and each of those needs its own serialization. The manual approach collapses under its own weight fast.

You don’t have to write any of it. This is a solved problem, and the solution ships with the language.

JSON.stringify

JSON (JavaScript Object Notation) is a text format for representing values and objects. It’s specified in RFC 8259 (which replaced the older RFC 4627). The syntax came from JavaScript, but the format is language-neutral: Python, Ruby, PHP, Java, Go, and nearly everything else has a JSON library. That’s the whole appeal. A JavaScript client can hand a JSON payload to a server written in any language, and both sides understand it.

JavaScript gives you two methods for the round trip:

  • JSON.stringify — object to JSON string (serialize).
  • JSON.parse — JSON string back to object (deserialize).
JS object
{ name: “Maya” }
JSON.stringify →← JSON.parse
JSON string
{“name”:“Maya”}
JSON is the text bridge between a live object and anything outside the current program.

Here’s JSON.stringify applied to a student record:

let student = {
  name: 'Maya',
  age: 30,
  isAdmin: false,
  courses: ['html', 'css', 'js'],
  spouse: null
};

let json = JSON.stringify(student);

alert(typeof json); // we've got a string!

alert(json);
/* JSON-encoded object:
{
  "name": "Maya",
  "age": 30,
  "isAdmin": false,
  "courses": ["html", "css", "js"],
  "spouse": null
}
*/

JSON.stringify(student) walks the object and returns a single string. That string goes by several names — JSON-encoded, serialized, stringified, or marshalled. All describe the same result: a flat piece of text ready to travel over the wire or land in a data store.

Change the fields below and watch the encoded string update. Notice that every key and every text value gets wrapped in double quotes, while numbers and booleans stay bare:

interactiveWatch a live object become a JSON string

The output looks close to a JavaScript object literal, but a few differences are strict, not stylistic:

  • Strings are wrapped in double quotes. JSON has no single quotes and no backticks, so 'Maya' comes out as "Maya".
  • Property names are double-quoted too, always. age: 30 becomes "age": 30.

Primitives work too

JSON.stringify isn’t limited to objects. Hand it a primitive and you get that primitive’s JSON form:

// a number in JSON is just a number
alert( JSON.stringify(1) ) // 1

// a string in JSON is still a string, but double-quoted
alert( JSON.stringify('test') ) // "test"

alert( JSON.stringify(true) ); // true

alert( JSON.stringify([1, 2, 3]) ); // [1,2,3]

The supported types

JSON is deliberately small. It supports exactly these:

  • Objects { ... }
  • Arrays [ ... ]
  • Primitives: strings, numbers, booleans (true / false), and null.
object { }array [ ]stringnumberbooleannull
The full JSON type vocabulary. Anything outside this set has no JSON representation.

Because JSON is a data-only, language-independent format, anything that’s purely a JavaScript notion gets dropped by JSON.stringify. Specifically:

  • Function properties (methods).
  • Symbolic keys and values (properties keyed by a Symbol).
  • Properties holding undefined.
let user = {
  sayHi() { // ignored
    alert("Hello");
  },
  [Symbol("id")]: 123, // ignored
  something: undefined // ignored
};

alert( JSON.stringify(user) ); // {} (empty object)

Every property here is one of the skipped kinds, so the result is an empty object. Usually that’s what you want. When it isn’t, you can override the behavior — the replacer argument and a custom toJSON (both below) give you control.

Nesting is automatic

The best part: nested objects and arrays serialize on their own, all the way down. You don’t lift a finger.

let webinar = {
  topic: "Product Launch",
  hall: {
    capacity: 40,
    speakers: ["maya", "priya"]
  }
};

alert( JSON.stringify(webinar) );
/* The whole structure is stringified:
{
  "topic":"Product Launch",
  "hall":{"capacity":40,"speakers":["maya","priya"]}
}
*/

The one hard limit: no circular references

There’s a catch that will bite you eventually. The object graph must be a tree, not a loop. If two objects point at each other, JSON.stringify has no way to write out an infinite chain, so it throws.

let hall = {
  capacity: 40
};

let webinar = {
  topic: "Product Launch",
  speakers: ["maya", "priya"]
};

webinar.venue = hall;       // webinar references hall
hall.bookedFor = webinar; // hall references webinar

JSON.stringify(webinar); // Error: Converting circular structure to JSON

webinar.venue points to hall, and hall.bookedFor points right back to webinar. Following the links never terminates.

webinar
venue →
→ venue →← bookedFor ←
hall
bookedFor →
A circular reference: each object reaches the other, so serialization would loop forever.

The next section shows how to cut a link like bookedFor so the rest can still serialize.

Excluding and transforming: replacer

The full signature of JSON.stringify takes three arguments:

let json = JSON.stringify(value[, replacer, space])
value

The value to encode.

replacer

An array of property names to keep, or a mapping function function(key, value).

space

How much indentation to add for pretty-printing.

Most of the time you pass just the value. But when you need to filter the output — say, to drop the circular link from earlier — the second argument earns its keep.

An allow-list array

Pass an array of property names and only those properties survive:

let hall = {
  capacity: 40
};

let webinar = {
  topic: "Product Launch",
  speakers: [{name: "Maya"}, {name: "Nadia"}],
  venue: hall // webinar references hall
};

hall.bookedFor = webinar; // hall references webinar

alert( JSON.stringify(webinar, ['topic', 'speakers']) );
// {"topic":"Product Launch","speakers":[{},{}]}

That’s a bit too aggressive. The allow-list applies to the whole structure, at every level. Since name isn’t on the list, the speaker objects come out empty — [{},{}].

So widen the list to name every property you want to keep, everywhere in the graph, while leaving out bookedFor (the one that closes the loop):

let hall = {
  capacity: 40
};

let webinar = {
  topic: "Product Launch",
  speakers: [{name: "Maya"}, {name: "Nadia"}],
  venue: hall // webinar references hall
};

hall.bookedFor = webinar; // hall references webinar

alert( JSON.stringify(webinar, ['topic', 'speakers', 'venue', 'name', 'capacity']) );
/*
{
  "topic":"Product Launch",
  "speakers":[{"name":"Maya"},{"name":"Nadia"}],
  "venue":{"capacity":40}
}
*/

Everything except bookedFor makes it through. It works, but the list is long and you have to remember to keep it in sync with the object. For anything but a small fixed shape, that’s fragile.

A replacer function

Instead of listing properties, hand JSON.stringify a function. It runs once for every (key, value) pair and returns the value to use in the output. Return the value unchanged to keep it, or return undefined to drop that property entirely.

For the circular case, the rule is simple: keep everything, except skip bookedFor.

let hall = {
  capacity: 40
};

let webinar = {
  topic: "Product Launch",
  speakers: [{name: "Maya"}, {name: "Nadia"}],
  venue: hall // webinar references hall
};

hall.bookedFor = webinar; // hall references webinar

alert( JSON.stringify(webinar, function replacer(key, value) {
  alert(`${key}: ${value}`);
  return (key == 'bookedFor') ? undefined : value;
}));

/* key:value pairs that come to replacer:
:             [object Object]
topic:        Product Launch
speakers:     [object Object],[object Object]
0:            [object Object]
name:         Maya
1:            [object Object]
name:         Nadia
venue:        [object Object]
capacity:     40
bookedFor: [object Object]
*/

The alert inside the function traces every pair the replacer sees, and there’s a lot to notice there.

The replacer visits every key/value pair, including entries nested deep inside objects and arrays — array indices (0, 1) show up as keys. It runs top-down and recursively. Inside the function, this is the object that currently holds the property being visited.

“” → the whole wrapper {“”: webinar}
topic → “Product Launch”
speakers → [ … ]
0 → {name}
name → “Maya”
1 → {name}
name → “Nadia”
venue → {capacity, bookedFor}
capacity → 40
bookedFor → skipped (returns undefined)
The replacer walks the whole tree top-down, one (key, value) pair at a time — array indices included.

That first line is special. The very first call uses a synthetic wrapper object, {"": webinar}. So the first pair has an empty-string key and the entire target object as its value — which is why the trace opens with ":[object Object]".

Why bother with the wrapper? It hands the replacer maximum reach. Even the top-level object passes through the function, so you can inspect, transform, or replace the whole thing before anything else runs.

Formatting: space

The third argument, space, controls pretty-printing — how many spaces to indent nested levels.

Everything so far came out as one dense line with no spaces. That’s ideal for transport: fewer bytes, nothing wasted. But it’s painful to read. space exists purely to make the output human-friendly.

Pass 2 and each nesting level gets a two-space indent, with nested objects broken onto their own lines:

let user = {
  name: "Maya",
  age: 25,
  roles: {
    isAdmin: false,
    isEditor: true
  }
};

alert(JSON.stringify(user, null, 2));
/* two-space indents:
{
  "name": "Maya",
  "age": 25,
  "roles": {
    "isAdmin": false,
    "isEditor": true
  }
}
*/

/* for JSON.stringify(user, null, 4) the result would be more indented:
{
    "name": "Maya",
    "age": 25,
    "roles": {
        "isAdmin": false,
        "isEditor": true
    }
}
*/

Note the null in the middle slot — that’s the replacer argument, left empty here because we only want formatting.

space can also be a string, in which case that string is used for each indent step instead of a count of spaces. Passing '\t' indents with tabs, for example.

Drag the slider to feel the difference. At 0 you get the compact transport form; bump it up and the same data spreads out for reading:

interactiveThe space argument, live

Custom “toJSON”

Just as an object can define toString to steer string conversion, it can define toJSON to steer JSON conversion. When JSON.stringify hits a value, it checks for a toJSON method and, if there is one, uses whatever that method returns in place of the object.

You’ve already benefited from this without noticing. Date objects have a built-in toJSON:

let hall = {
  capacity: 40
};

let webinar = {
  topic: "Product Launch",
  date: new Date(Date.UTC(2017, 0, 1)),
  hall
};

alert( JSON.stringify(webinar) );
/*
  {
    "topic":"Product Launch",
    "date":"2017-01-01T00:00:00.000Z",  // (1)
    "hall": {"capacity":40}             // (2)
  }
*/

At (1), date came out as an ISO string. That’s Date.prototype.toJSON doing its job — it returns that standardized string, and JSON.stringify uses it.

Now give hall (2) its own toJSON:

let hall = {
  capacity: 40,
  toJSON() {
    return this.capacity;
  }
};

let webinar = {
  topic: "Product Launch",
  hall
};

alert( JSON.stringify(hall) ); // 40

alert( JSON.stringify(webinar) );
/*
  {
    "topic":"Product Launch",
    "hall": 40
  }
*/

toJSON kicks in both ways: when you stringify hall directly, and when hall sits nested inside webinar. Either way, the object is replaced by its toJSON result before encoding.

hall
{capacity:40, toJSON}
toJSON() →
40
→ encoded as
40
toJSON runs first: the object hands back a stand-in value, and only that value gets encoded.

JSON.parse

Serialization is only half the trip. To turn a JSON string back into a live object, use JSON.parse.

let value = JSON.parse(str[, reviver]);
str

The JSON string to parse.

reviver

Optional function(key, value) called for every pair, letting you transform values as they’re read.

A stringified array comes back as a real array:

// stringified array
let numbers = "[0, 1, 2, 3]";

numbers = JSON.parse(numbers);

alert( numbers[1] ); // 1

Nested structures rebuild fully:

let userData = '{ "name": "Maya", "age": 35, "isAdmin": false, "friends": [0,1,2,3] }';

let user = JSON.parse(userData);

alert( user.friends[1] ); // 1

The input can be arbitrarily deep — objects inside arrays inside objects — as long as it’s valid JSON.

Common hand-written JSON mistakes

Sometimes you write JSON by hand, usually while debugging. That’s where the strict rules trip people up. Every line below except the last is invalid:

let json = `{
  name: "Maya",                     // mistake: property name without quotes
  "surname": 'Vance',               // mistake: single quotes in value (must be double)
  'isAdmin': false                  // mistake: single quotes in key (must be double)
  "birthday": new Date(2000, 2, 3), // mistake: no "new" is allowed, only bare values
  "friends": [0,1,2,3]              // here all fine
}`;

There’s a separate format, JSON5, that relaxes these rules — unquoted keys, comments, trailing commas, and more. But JSON5 is a third-party library, not part of the JavaScript language or the JSON standard.

The strictness isn’t laziness on the spec authors’ part. Tight, unambiguous rules are exactly what let parsers be simple, fast, and reliable across every language that implements them.

JSON.parse throws on bad input

One more thing worth internalizing: invalid JSON doesn’t return null or undefined, it throws a SyntaxError. Any code parsing data you didn’t produce yourself — a network response, a file, user input — should wrap the call in try...catch:

try {
  let data = JSON.parse(maybeBrokenString);
} catch (e) {
  alert("Bad JSON: " + e.message);
}

Try it yourself. Edit the text below — remove a quote, add a trailing comma, use single quotes — and hit Parse. Valid input reports the parsed keys; invalid input shows the exact SyntaxError caught by try...catch:

interactiveA guarded JSON.parse

Using reviver

Say the server sends you a stringified webinar:

// topic: (webinar topic), date: (webinar date)
let str = '{"topic":"Product Launch","date":"2017-11-30T12:00:00.000Z"}';

You need to deserialize it into a usable object. Straightforward parse:

let str = '{"topic":"Product Launch","date":"2017-11-30T12:00:00.000Z"}';

let webinar = JSON.parse(str);

alert( webinar.date.getDate() ); // Error!

It throws. And the reason is the same limitation as before: webinar.date is a plain string, not a Date. JSON has no date type, so the date arrived as text. JSON.parse had no way to know that particular string was meant to become a Date object.

This is where the second argument, the reviver, comes in. It’s a function that runs on every parsed pair, and its return value replaces the parsed one. Return everything as-is, except turn date into a real Date:

let str = '{"topic":"Product Launch","date":"2017-11-30T12:00:00.000Z"}';

let webinar = JSON.parse(str, function(key, value) {
  if (key == 'date') return new Date(value);
  return value;
});

alert( webinar.date.getDate() ); // now works!
Date object
stringify →
“2017-11-30T…Z”
parse →
still a string
reviver →
Date object
stringify flattens the Date to text; parse alone leaves it as text; the reviver rebuilds the Date on the way in.

The same reviver handles nested data without extra work, because it visits every pair at every depth:

let schedule = `{
  "webinars": [
    {"topic":"Product Launch","date":"2017-11-30T12:00:00.000Z"},
    {"topic":"Design Review","date":"2017-04-18T12:00:00.000Z"}
  ]
}`;

schedule = JSON.parse(schedule, function(key, value) {
  if (key == 'date') return new Date(value);
  return value;
});

alert( schedule.webinars[1].date.getDate() ); // works!

Toggle the reviver on and off to see the difference concretely. Without it, date stays a string and calling a Date method fails; with it, date comes back as a real Date you can query:

interactiveReviver: string in, Date out

Summary

  • JSON is a compact, text-based data format with its own standard, supported by libraries in most programming languages.
  • It represents plain objects, arrays, strings, numbers, booleans, and null — and nothing else. Functions, symbols, and undefined are dropped; NaN/Infinity become null; BigInt throws.
  • JSON.stringify serializes a value to JSON; JSON.parse builds a value back from JSON. Bad input makes JSON.parse throw, so guard it.
  • Object graphs must be trees. Circular references make JSON.stringify throw — use a replacer to cut the offending link.
  • JSON.stringify takes a replacer (an allow-list array or a mapping function) and a space argument for pretty-printing. JSON.parse takes a reviver to transform values as they’re read.
  • If an object defines toJSON, JSON.stringify calls it and encodes the result instead — that’s how Date serializes to an ISO string automatically.