Aprende leyendo en orden

Métodos especiales — toString, toJSON y Symbol.iterator

Verás toString, toJSON y Symbol.iterator, que JavaScript llama por ti: cómo se ve un objeto como texto, qué va al JSON y cómo recorrer una clase con for...of.

Un objeto de pedido aparece como [object Object] en un template literal, pierde el total que calcula su getter al pasar por JSON.stringify y lanza un TypeError en un bucle for...of.

En este artículo verás tres métodos especiales (métodos que JavaScript busca por su nombre y llama en situaciones concretas): los que se usan al mostrar un valor, al convertirlo a JSON y al recorrerlo.

Cómo se ve un objeto dentro de un texto — toString

Supón que incluyes un pedido en una notificación escribiendo Recibido: ${order}. En lugar del número de pedido o del importe, ves [object Object]. Si en cada pantalla armas el texto con order.id y order.total, el mismo código para construir la cadena termina repetido por todas partes.

toString es un método que JavaScript llama cuando necesita convertir un valor en cadena, y lo que devuelve es la cadena que se usa. Lo llaman ${}, String() y el join de los arrays. Si tu clase no lo define, el toString por defecto de Object.prototype, más arriba en la cadena, devuelve [object Object].

// Una clase sin toString
class DraftOrder {
  constructor(id, total) { this.id = id; this.total = total; }
}

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

// Al ponerlo en un template literal, se llama a toString
console.log(`Recibido: ${draft}`);       // Recibido: [object Object]
console.log(`Recibido: ${order}`);       // Recibido: Pedido A-102 (4800 yenes)

// String() y el join de un array usan el mismo toString
console.log(String(order));               // Pedido A-102 (4800 yenes)
console.log([order, new Order("A-103", 1500)].join(" / "));   // Pedido A-102 (4800 yenes) / Pedido A-103 (1500 yenes)
Dónde se encuentra el toString que llama ${}
Recibido: ${draft}DraftOrder.prototypeno tiene toStringSe encuentra enObject.prototypeRecibido:[object Object]Recibido: ${order}Se encuentra enOrder.prototypeLlama al toStringde OrderRecibido: PedidoA-102 (4800 yenes)
Ninguna de las dos instancias tiene un toString propio, así que JavaScript recorre la cadena. Si la clase lo define, se encuentra antes que el de Object.prototype.

El código que llama, ${}, es el mismo en las dos filas. Para cambiar el formato basta con editar el toString de la clase en un solo lugar, y todas las líneas que insertan el objeto reflejan el cambio. Si pasas la instancia directamente, como en console.log(order), no se llama a toString.

Vas a mostrar los autobuses que salen en un panel frente a una estación. BusDeparture y departures ya están declarados.

① Escribe un método que devuelva un texto con la forma «Ruta 42 [Andén 2] a Hospital Municipal».

② Pon departures[0] en ${} y muéstralo después de «Sale pronto: ».

③ Une departures con « / », pasando los objetos tal cual, y muestra el resultado.

④ Cambia el andén del segundo autobús a 3, ponlo en ${} y muéstralo.

(Si se ejecuta correctamente, aparecerá una explicación.)

Editor JavaScript / TypeScript

Ejecutar el código para ver el resultado

Incluir el total en el JSON que guardas — toJSON

Para enviar un pedido a un servidor, lo conviertes con JSON.stringify(order). En una clase que guarda las líneas del pedido en #lines y calcula el total con un getter total, ninguno de los dos es una propiedad normal de la instancia, así que lo único que se envía es {"id":"A-102"}.

Si escribes toJSON (un método que JSON.stringify llama antes de convertir un valor, y cuyo valor devuelto convierte en su lugar), puedes devolver un objeto con exactamente los valores que quieres enviar. Un Date se convierte en una cadena de fecha y hora en el JSON justamente porque Date tiene este método.

class Order {
  #lines;                                        // Líneas del pedido (no se leen desde afuera)
  constructor(id, lines) { this.id = id; this.#lines = lines; }
  get total() { return this.#lines.reduce((sum, line) => sum + line.price * line.quantity, 0); }

  // Lo llama JSON.stringify; lo que se convierte es su valor devuelto
  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 }]);

// La única clave propia de la instancia es id
console.log(Object.keys(order).join(", "));   // id

// Lo que se convierte es el valor devuelto por toJSON
console.log(JSON.stringify(order));           // {"id":"A-102","itemCount":2,"total":4800}

// Dentro de un array, se llama a toJSON para cada elemento
console.log(JSON.stringify([order, other]));
// [{"id":"A-102","itemCount":2,"total":4800},{"id":"A-103","itemCount":1,"total":1500}]
Cómo cambian el resultado toJSON y los arrays
Se pasa aJSON.stringifyorder sintoJSONorder contoJSON[order, other](con toJSON)Lee solo id,su única claveLee lo quedevuelve toJSON()Llama a toJSON()por cada elemento{"id":"A-102"}{"id":"A-102",…"total":4800}[{…"total":4800},{…"total":1500}]
Sin toJSON, solo se lee id, la única clave propia de la instancia. Con toJSON, lo que se convierte es su valor devuelto, y también se llama para cada elemento de un array.

#lines sigue sin poder leerse desde afuera: es la propia clase la que elige qué valores van al JSON. Incluso un valor que la instancia no guarda como propiedad, como itemCount, puede aparecer en el JSON si lo agregas al objeto que devuelves. Si vuelves a convertir la cadena con JSON.parse, obtienes un objeto plano con esta forma, no una instancia de Order.

Si toJSON devuelve una cadena, se convierte dos veces

Si devuelves una cadena, como en return JSON.stringify({ id: this.id, total: this.total });, esa cadena se vuelve a convertir a JSON. Aun después de un JSON.parse, sigue siendo una cadena, y .total da undefined. Devuelve un objeto.

Vas a dejar el número de tarjeta fuera de los datos de socio que se envían al servidor como JSON. Member, member y sentAt ya están declarados.

① Escribe un método que devuelva solo el número de socio y el nombre cuando el objeto se convierte a JSON.

② Convierte member tal cual en una cadena JSON y muéstrala.

③ Reúne sentAt y member en un solo objeto, conviértelo en una cadena JSON y muéstrala.

④ Muestra si la cadena de ③ contiene el número de tarjeta.

Editor JavaScript / TypeScript

Ejecutar el código para ver el resultado

Recorrer las líneas del pedido con for...of — Symbol.iterator

Ahora quieres mostrar en pantalla las líneas de un pedido, una por fila. Las líneas están ocultas en #lines, así que no se pueden leer desde afuera, y escribir for (const line of order) lanza TypeError: order is not iterable. Aunque guardaras las líneas en un array público, igual no podrías recorrer la instancia en sí.

Si defines un método cuyo nombre es Symbol.iterator (un valor integrado y único que se usa como nombre del método al que llama for...of), for...of obtiene los valores de uno en uno llamando a next() sobre lo que devuelve ese método.

En una clase, un método generador function* nombre() se escribe como *nombre(). Si escribes el nombre entre corchetes, como [Symbol.iterator], el valor que va dentro de los corchetes se usa como nombre del método.

class Order {
  #lines;                                        // Líneas del pedido (no se leen desde afuera)
  constructor(id, lines) { this.id = id; this.#lines = lines; }
  // El método que llama for...of. Cada línea entregada con yield va a la variable del bucle
  *[Symbol.iterator]() {
    for (const line of this.#lines) yield line;
  }
}
const order = new Order("A-102", [{ name: "Café de filtro", quantity: 2 }, { name: "Taza", quantity: 1 }]);

// Obtén las líneas del pedido de una en una desde fuera de la clase
for (const line of order) {
  console.log(`${line.name} × ${line.quantity}`);      // 2 líneas: Café de filtro × 2 / Taza × 1
}

// Cada spread vuelve a llamar al método, así que el segundo también obtiene 2 elementos
console.log([...order].length, [...order].length);   // 2 2
Un generador nuevo cada vez
for (const lineof order)[Symbol.iterator]()→ muestra 2 líneas[...order]1.ª vezVuelve a llamarlo:2 elementos[...order]2.ª vezVuelve a llamarlo:otra vez 2
Las tres formas llaman a [Symbol.iterator]() en ese punto. El segundo spread también empieza desde el principio y obtiene los 2 elementos.

Un generador que ya se leyó hasta el final queda vacío, como viste en el artículo de iteradores y generadores. Quien llama solo recibe las líneas entregadas con yield, nunca el array #lines en sí, así que no puede agregar ni quitar líneas desde afuera.

Devolver el propio array rompe for...of

Si devuelves el array, como en return this.#lines;, los arrays no tienen next(), así que for...of lanza TypeError: undefined is not a function. Un array es iterable, pero no es un iterador. Agrega * y entrega cada elemento con yield.

Vas a hacer que un carrito se pueda recorrer sin exponer su array interno. Cart y cart ya están declarados.

① Haz que se puedan obtener los productos de uno en uno con for...of.

② Recorre cart tal cual con for...of y muestra cada producto con cantidad 1 o más con la forma «nombre × cantidad».

③ Expande cart tal cual en un array y muestra el precio total con la forma «Total: ... yenes».

④ Desestructura cart para obtener el primer producto y muéstralo con la forma «Primero: nombre».

Editor JavaScript / TypeScript

Ejecutar el código para ver el resultado

Encontrar el método que falta a partir del resultado — los tres de un vistazo

Cuando lo que se muestra o el JSON no sale como esperabas, líneas como ${order} y JSON.stringify(order) no mencionan el nombre del método que llaman. Para averiguar qué método olvidaste escribir, razona hacia atrás a partir de cómo se usa el objeto y de la forma del resultado.

Incluso en una instancia de una clase que no tiene ninguno de los tres métodos, cada forma de llamada busca su propio nombre fijo a lo largo de la cadena de prototipos. El único que encuentra es el toString por defecto de Object.prototype; toJSON y [Symbol.iterator] no aparecen aunque se busque hasta llegar a null.

// Una clase sin toString, toJSON ni [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: "Café de filtro", price: 1200, quantity: 2 }]);

// toString se encuentra en Object.prototype; como no hay toJSON, solo se listan sus claves propias
console.log(`${draft}`);              // [object Object]
console.log(JSON.stringify(draft));   // {"id":"A-101"}

// [Symbol.iterator] no existe en ningún lado, así que se lanza un error
try {
  console.log([...draft]);
} catch (error) {
  console.log(error.message);         // draft is not iterable
}
Resultados de un draft sin ninguno de los métodos
${draft}JSON.stringify(draft)[...draft]Busca toStringBusca toJSONBusca[Symbol.iterator]Se encuentra enObject.prototypeNo aparece entoda la cadenaNo aparece entoda la cadena[object Object]{"id":"A-101"}(sin total)TypeError: draftis not iterable
Cada una de las tres formas busca un nombre distinto en la cadena. Solo [Symbol.iterator] lanza un error; las otras dos devuelven resultados por defecto.

JSON.stringify no lanza ningún error: devuelve {"id":"A-101"}, así que los datos pueden enviarse sin las líneas de #lines ni el total, y nadie lo nota hasta que llegan al otro lado. La tabla de abajo reúne otras formas que buscan los mismos nombres y qué pasa cuando el método no está escrito.

Forma de llamadaMétodo que buscaResultado sin el método
JSON.stringify([draft])toJSON (por elemento)[{"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

Verificación de conocimientos

Responde cada pregunta una a una.

Pregunta 1Si draft es una instancia de una clase sin ninguno de los tres métodos, ¿qué pasa al ejecutar const [firstLine] = draft;?

Pregunta 2¿Qué pasa al aplicar JSON.stringify a un array de dos pedidos cuyo toJSON devuelve { id: this.id }?

Pregunta 3Si escribes [...cart] dos veces con un cart que tiene *[Symbol.iterator](), ¿qué longitud tiene el segundo array?