Q1For draft, from a class with none of the three methods, what happens when you run const [firstLine] = draft;?
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)
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.
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}]
#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.
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 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.
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
}
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 form | Method it looks for | Result 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 |
Knowledge Check
Answer each question one by one.
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?