Learn by reading through in order

Special Methods — toString, toJSON, and Symbol.iterator

Covers toString, toJSON, and Symbol.iterator, which JavaScript calls for you: control an object's string form, choose what goes into JSON, and loop over a class with for...of.

An order object shows up as [object Object] in a template literal, loses its getter-computed total in JSON.stringify, and throws a TypeError in a for...of loop.

This article covers three special methods (methods that JavaScript looks up by name and calls in specific situations): the ones called for display, for JSON conversion, and for iteration.

Setting How an Object Looks Inside a String — toString

Suppose you include an order in a notification by writing Received: ${order}. Instead of the order number or the amount, you see [object Object]. If you rebuild the text from order.id and order.total on every screen, the same string-building code ends up everywhere.

toString (a method JavaScript calls when it needs a value as a string, using the return value as that string) is called by ${}, String(), and an array's join. If your class doesn't define one, the default toString from Object.prototype, further up the chain, returns [object Object].

// A class without toString
class DraftOrder {
  constructor(id, total) { this.id = id; this.total = total; }
}

// A class with toString
class Order {
  constructor(id, total) { this.id = id; this.total = total; }
  toString() { return `Order ${this.id} (${this.total} yen)`; }
}
const draft = new DraftOrder("A-101", 3200);
const order = new Order("A-102", 4800);

// Putting it in a template literal calls toString
console.log(`Received: ${draft}`);       // Received: [object Object]
console.log(`Received: ${order}`);       // Received: Order A-102 (4800 yen)

// String() and an array's join use the same toString
console.log(String(order));               // Order A-102 (4800 yen)
console.log([order, new Order("A-103", 1500)].join(" / "));   // Order A-102 (4800 yen) / Order A-103 (1500 yen)
Where the toString Called by ${} Is Found
Received: ${draft}DraftOrder.prototypehas no toStringFound onObject.prototypeReceived:[object Object]Received: ${order}Found onOrder.prototypeCalls Order'stoStringReceived: OrderA-102 (4800 yen)
Neither instance has its own toString, so JavaScript searches the chain. If the class defines one, it's found before Object.prototype's.

The calling side, ${}, is the same in both rows. To change the display format, you edit the class's toString in one place, and every line that embeds the object picks up the change. When you pass the instance directly, as in console.log(order), toString isn't called.

You'll list departing buses on a display board in front of a station. BusDeparture and departures are already declared.

① Write a method that returns text in the form “Route 42 [Platform 2] to City Hospital”.

② Put departures[0] in ${} and print it after “Departing soon: ”.

③ Join departures with “ / ”, passing the objects directly, and print the result.

④ Change the second bus's platform to 3, then put it in ${} and print it.

(If it runs correctly, an explanation will appear.)

JavaScript / TypeScript Editor

Run code to see output

Including the Total in the JSON You Save — toJSON

To send an order to a server, you convert it with JSON.stringify(order). In a class that keeps its line items in #lines and computes the total with a total getter, neither is a regular property of the instance, so all that gets sent is {"id":"A-102"}.

If you write toJSON (a method JSON.stringify calls before converting a value, and whose return value it converts instead), you can return an object with exactly the values you want to send. A Date turns into a date-time string in JSON because Date has this method.

class Order {
  #lines;                                        // Line items (not readable from outside)
  constructor(id, lines) { this.id = id; this.#lines = lines; }
  get total() { return this.#lines.reduce((sum, line) => sum + line.price * line.quantity, 0); }

  // Called by JSON.stringify; the return value is what gets converted
  toJSON() { return { id: this.id, itemCount: this.#lines.length, total: this.total }; }
}
const order = new Order("A-102", [{ price: 1200, quantity: 2 }, { price: 2400, quantity: 1 }]);
const other = new Order("A-103", [{ price: 1500, quantity: 1 }]);

// The instance's only own key is id
console.log(Object.keys(order).join(", "));   // id

// The return value of toJSON is what gets converted
console.log(JSON.stringify(order));           // {"id":"A-102","itemCount":2,"total":4800}

// Inside an array, toJSON is called for each element
console.log(JSON.stringify([order, other]));
// [{"id":"A-102","itemCount":2,"total":4800},{"id":"A-103","itemCount":1,"total":1500}]
How toJSON and Arrays Change the Result
Pass toJSON.stringifyorder withouttoJSONorder withtoJSON[order, other](with toJSON)Reads only id,its one keyReads whattoJSON() returnsCalls toJSON()for each element{"id":"A-102"}{"id":"A-102",…"total":4800}[{…"total":4800},{…"total":1500}]
Without toJSON, only id, the instance's one own key, is read. With toJSON, its return value is what gets converted, and it's also called for each element of an array.

#lines still can't be read from outside; the class itself chooses which values go into the JSON. Even a value the instance doesn't hold as a property, like itemCount, can appear in the JSON if you add it to the returned object. If you parse the string back with JSON.parse, you get a plain object in this shape, not an Order instance.

If toJSON Returns a String, It Gets Converted Twice

If you return a string, as in return JSON.stringify({ id: this.id, total: this.total });, that string gets converted to JSON again. Even after one JSON.parse, it's still a string, and .total is undefined. Return an object instead.

You'll keep the card number out of the member data sent to a server as JSON. Member, member, and sentAt are already declared.

① Write a method that returns only the member ID and name when the object is converted to JSON.

② Convert member directly to a JSON string and print it.

③ Combine sentAt and member into one object, convert it to a JSON string, and print it.

④ Print whether the string from ③ contains the card number.

JavaScript / TypeScript Editor

Run code to see output

Looping Over Line Items with for...of — Symbol.iterator

Next, you want to show an order's line items on screen, one per row. The items are hidden in #lines, so they can't be read from outside, and writing for (const line of order) throws TypeError: order is not iterable. Even if you kept the items in a public array, you still couldn't loop over the instance itself.

If you define a method whose name is Symbol.iterator (a built-in, unique value used as the name of the method that for...of calls), for...of gets values one at a time by calling next() on what that method returns.

In a class, a generator method function* name() is written as *name(). Writing the name in square brackets, as [Symbol.iterator], uses the value inside the brackets as the method name.

class Order {
  #lines;                                        // Line items (not readable from outside)
  constructor(id, lines) { this.id = id; this.#lines = lines; }
  // The method for...of calls. Each yielded item goes into the loop variable
  *[Symbol.iterator]() {
    for (const line of this.#lines) yield line;
  }
}
const order = new Order("A-102", [{ name: "Drip coffee", quantity: 2 }, { name: "Mug", quantity: 1 }]);

// Get the line items one at a time from outside the class
for (const line of order) {
  console.log(`${line.name} × ${line.quantity}`);      // 2 lines: Drip coffee × 2 / Mug × 1
}

// Each spread calls the method again, so the second one also gets 2 items
console.log([...order].length, [...order].length);   // 2 2
A New Generator Each Time
for (const lineof order)[Symbol.iterator]()→ prints 2 lines[...order]1st timeCalls it again,so 2 items[...order]2nd timeCalls it again, so2 items once more
All three forms call [Symbol.iterator]() at that point. The second spread also starts from the beginning and gets both items.

A generator that has been read to the end stays empty, as covered in the iterators and generators article. The caller only gets the yielded items, never the #lines array itself, so it can't add or remove line items from outside.

Returning the Array Itself Breaks for...of

If you return the array, as in return this.#lines;, arrays don't have a next(), so for...of throws TypeError: undefined is not a function. An array is iterable, but it isn't an iterator. Add * and yield each element.

You'll make a cart loopable without exposing its internal array. Cart and cart are already declared.

① Make it possible to get the products one at a time with for...of.

② Loop over cart directly with for...of and print each product whose quantity is 1 or more as “name × quantity”.

③ Spread cart directly into an array and print the total price as “Total: ... yen”.

④ Destructure cart to get the first product and print it as “First: name”.

JavaScript / TypeScript Editor

Run code to see output

Finding the Missing Method from the Result — All Three at a Glance

When the display or the JSON doesn't come out the way you expected, lines like ${order} and JSON.stringify(order) don't mention the name of the method they call. To figure out which method you forgot to write, work backward from how it's called and the shape of the result.

Even on an instance of a class that has none of the three methods, each calling form looks up its own fixed name along the prototype chain. The only one it finds is the default toString on Object.prototype; toJSON and [Symbol.iterator] aren't found even after searching all the way up to null.

// A class with none of toString, toJSON, or [Symbol.iterator]
class DraftOrder {
  #lines;
  constructor(id, lines) { this.id = id; this.#lines = lines; }
  get total() { return this.#lines.reduce((sum, line) => sum + line.price * line.quantity, 0); }
}
const draft = new DraftOrder("A-101", [{ name: "Drip coffee", price: 1200, quantity: 2 }]);

// toString is found on Object.prototype; there's no toJSON, so only its own keys are listed
console.log(`${draft}`);              // [object Object]
console.log(JSON.stringify(draft));   // {"id":"A-101"}

// [Symbol.iterator] doesn't exist anywhere, so it throws
try {
  console.log([...draft]);
} catch (error) {
  console.log(error.message);         // draft is not iterable
}
Results for a draft with None of the Methods
${draft}JSON.stringify(draft)[...draft]Looks for toStringLooks for toJSONLooks for[Symbol.iterator]Found onObject.prototypeNot found, evenup to nullNot found, evenup to null[object Object]{"id":"A-101"}(no total)TypeError: draftis not iterable
The three forms each search the chain for a different name. Only [Symbol.iterator] throws; the other two return default results.

JSON.stringify doesn't throw; it returns {"id":"A-101"}, so data missing the #lines items and total can be sent without anyone noticing until the receiving side does. The table below lists other forms that look up the same names, and what happens when the method isn't written.

Calling formMethod it looks forResult without the method
JSON.stringify([draft])toJSON (per element)[{"id":"A-101"}]
for (const line of draft)[Symbol.iterator]TypeError: draft is not iterable
const [firstLine] = draft[Symbol.iterator]TypeError: draft is not iterable
QUIZ

Knowledge Check

Answer each question one by one.

Q1For draft, from a class with none of the three methods, what happens when you run const [firstLine] = draft;?

Q2What happens when you JSON.stringify an array of two orders whose toJSON returns { id: this.id }?

Q3If you write [...cart] twice for a cart with *[Symbol.iterator](), how long is the second array?