Apprenez en lisant dans l'ordre

Méthodes spéciales — toString, toJSON et Symbol.iterator

Les méthodes que JavaScript appelle pour toi : toString pour l'affichage, toJSON pour le contenu JSON, Symbol.iterator pour for...of.

Un objet commande s'affiche [object Object] dans un littéral de gabarit, perd son total calculé par un getter dans JSON.stringify, et lève une TypeError dans une boucle for...of.

Cet article présente trois méthodes spéciales (des méthodes que JavaScript cherche par leur nom et appelle dans des situations précises) : celle de l'affichage, celle de la conversion en JSON et celle de l'itération.

Choisir l'affichage d'un objet dans une chaîne — toString

Tu insères une commande dans une notification en écrivant Reçue : ${order}. Au lieu du numéro de commande ou du montant, tu vois [object Object]. Si tu reconstruis le texte à partir de order.id et order.total sur chaque écran, le même code d'assemblage se retrouve partout.

toString (la méthode que JavaScript appelle pour convertir un objet en chaîne, et dont il utilise la valeur de retour) est appelée par ${}, String() et la méthode join des tableaux. Si ta classe n'en définit pas, c'est le toString par défaut d'Object.prototype, plus haut dans la chaîne, qui renvoie [object Object].

// Une classe sans toString
class DraftOrder {
  constructor(id, total) { this.id = id; this.total = total; }
}

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

// Dans un littéral de gabarit, toString est appelé
console.log(`Reçue : ${draft}`);         // Reçue : [object Object]
console.log(`Reçue : ${order}`);         // Reçue : Commande A-102 (4800 yens)

// String() et le join des tableaux utilisent le même toString
console.log(String(order));               // Commande A-102 (4800 yens)
console.log([order, new Order("A-103", 1500)].join(" / "));   // Commande A-102 (4800 yens) / Commande A-103 (1500 yens)
Où ${} trouve le toString qu'il appelle
Reçue : ${draft}DraftOrder.prototypesans toStringTrouvé dansObject.prototypeReçue :[object Object]Reçue : ${order}Trouvé dansOrder.prototypeAppelle letoString d'OrderReçue : CommandeA-102 (4800 yens)
Aucune des deux instances n'a son propre toString : JavaScript parcourt la chaîne. Si la classe en définit un, il est trouvé avant celui d'Object.prototype.

Le côté appelant, ${}, est le même dans les deux rangées. Pour changer le format d'affichage, tu modifies le toString de la classe à un seul endroit, et chaque ligne qui insère l'objet en tient compte. Quand tu passes l'instance telle quelle, comme dans console.log(order), toString n'est pas appelé.

Tu affiches les bus au départ sur le panneau devant une gare. BusDeparture et departures sont déjà déclarés.

① Écris une méthode qui renvoie un texte de la forme « Ligne 42 [quai 2] direction Hôpital municipal ».

② Place departures[0] dans ${} et affiche-le après « Départ imminent : ».

③ Joins departures avec « / » en passant directement les objets, puis affiche le résultat.

④ Passe le quai du deuxième bus à 3, place ce bus dans ${} et affiche-le.

(Si tout s'exécute correctement, une explication apparaîtra.)

Éditeur JavaScript / TypeScript

Exécuter le code pour voir le résultat

Inclure le total dans le JSON enregistré — toJSON

Pour envoyer une commande à un serveur, tu la convertis avec JSON.stringify(order). Dans une classe qui garde ses lignes dans #lines et calcule le total avec un getter total, aucun des deux n'est une propriété ordinaire de l'instance : seul {"id":"A-102"} est envoyé.

Si tu écris toJSON (la méthode que JSON.stringify appelle d'abord, pour convertir sa valeur de retour à la place de l'objet), tu peux renvoyer un objet qui contient exactement les valeurs à envoyer. Si un objet Date devient une chaîne de date et d'heure en JSON, c'est justement parce que Date possède cette méthode.

class Order {
  #lines;                                        // Lignes de commande (illisibles de l'extérieur)
  constructor(id, lines) { this.id = id; this.#lines = lines; }
  get total() { return this.#lines.reduce((sum, line) => sum + line.price * line.quantity, 0); }

  // Appelée par JSON.stringify : c'est la valeur renvoyée qui est convertie
  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 seule clé propre de l'instance est id
console.log(Object.keys(order).join(", "));   // id

// C'est la valeur renvoyée par toJSON qui est convertie
console.log(JSON.stringify(order));           // {"id":"A-102","itemCount":2,"total":4800}

// Dans un tableau, toJSON est appelée pour chaque élément
console.log(JSON.stringify([order, other]));
// [{"id":"A-102","itemCount":2,"total":4800},{"id":"A-103","itemCount":1,"total":1500}]
Ce que JSON.stringify convertit, avec ou sans toJSON
Passé àJSON.stringifyordersans toJSONorderavec toJSON[order, other](avec toJSON)Ne lit que id,sa seule cléLit ce querenvoie toJSON()toJSON() appelésur chaque élément{"id":"A-102"}{"id":"A-102",…"total":4800}[{…"total":4800},{…"total":1500}]
Sans toJSON, seul id, l'unique clé propre de l'instance, est lu. Avec toJSON, c'est sa valeur de retour qui est convertie, et elle est aussi appelée pour chaque élément d'un tableau.

#lines reste illisible de l'extérieur : c'est la classe elle-même qui choisit les valeurs qui partent dans le JSON. Même une valeur que l'instance ne possède pas comme propriété, comme itemCount, peut apparaître dans le JSON si tu l'ajoutes à l'objet renvoyé. Si tu relis la chaîne avec JSON.parse, tu obtiens un objet ordinaire de cette forme, et non une instance d'Order.

Si toJSON renvoie une chaîne, elle est convertie deux fois

Si tu renvoies une chaîne, comme dans return JSON.stringify({ id: this.id, total: this.total });, cette chaîne est à nouveau convertie en JSON. Après un seul JSON.parse, tu as encore une chaîne, et .total vaut undefined. Renvoie plutôt un objet.

Tu retires le numéro de carte des données de membre envoyées en JSON à un serveur. Member, member et sentAt sont déjà déclarés.

① Écris une méthode qui ne renvoie que le numéro de membre et le nom lors de la conversion en JSON.

② Convertis directement member en chaîne JSON et affiche-la.

③ Regroupe sentAt et member dans un seul objet, convertis-le en chaîne JSON et affiche-la.

④ Affiche si la chaîne de ③ contient le numéro de carte.

Éditeur JavaScript / TypeScript

Exécuter le code pour voir le résultat

Parcourir les lignes de commande avec for...of — Symbol.iterator

Tu veux maintenant afficher les lignes d'une commande à l'écran, une par rangée. Elles sont cachées dans #lines, donc illisibles de l'extérieur, et écrire for (const line of order) lève TypeError: order is not iterable (l'objet n'est pas itérable). Même si tu gardais les lignes dans un tableau public, tu ne pourrais toujours pas parcourir l'instance elle-même.

Si tu définis une méthode nommée Symbol.iterator (une valeur intégrée et unique, qui sert de nom à la méthode qu'appelle for...of), for...of obtient les valeurs une par une en appelant next() sur ce que renvoie cette méthode.

Dans une classe, une méthode génératrice function* nom() s'écrit *nom(). Si tu écris le nom entre crochets, comme [Symbol.iterator], c'est la valeur entre les crochets qui sert de nom à la méthode.

class Order {
  #lines;                                        // Lignes de commande (illisibles de l'extérieur)
  constructor(id, lines) { this.id = id; this.#lines = lines; }
  // La méthode qu'appelle for...of. Chaque ligne passée à yield arrive dans la variable de boucle
  *[Symbol.iterator]() {
    for (const line of this.#lines) yield line;
  }
}
const order = new Order("A-102", [{ name: "Café filtre", quantity: 2 }, { name: "Mug", quantity: 1 }]);

// On récupère les lignes une par une depuis l'extérieur de la classe
for (const line of order) {
  console.log(`${line.name} × ${line.quantity}`);      // 2 lignes : Café filtre × 2 / Mug × 1
}

// Chaque décomposition rappelle la méthode : la deuxième obtient aussi 2 éléments
console.log([...order].length, [...order].length);   // 2 2
Un nouveau générateur à chaque fois
for (const lineof order)[Symbol.iterator]()→ affiche 2 lignes[...order]1re foisNouvel appel,donc 2 éléments[...order]2e foisNouvel appel,encore 2 éléments
Chacune des trois lignes appelle [Symbol.iterator]() de nouveau. La deuxième décomposition repart elle aussi du début et obtient les deux éléments.

Un générateur parcouru jusqu'au bout ne produit plus rien, comme on l'a vu dans l'article sur les itérateurs et les générateurs. L'appelant ne reçoit que les lignes passées à yield, jamais le tableau #lines lui-même : il ne peut donc ni ajouter ni supprimer de lignes depuis l'extérieur.

Renvoyer le tableau lui-même casse for...of

Si tu renvoies le tableau, comme dans return this.#lines;, for...of lève TypeError: undefined is not a function, car un tableau n'a pas de next(). Un tableau est itérable, mais ce n'est pas un itérateur. Ajoute * et passe chaque élément à yield.

Tu rends un panier parcourable sans exposer son tableau interne. Cart et cart sont déjà déclarés.

① Fais en sorte que for...of puisse récupérer les produits un par un.

② Parcours directement cart avec for...of et affiche chaque produit dont la quantité vaut 1 ou plus sous la forme « nom × quantité ».

③ Décompose directement cart dans un tableau et affiche le prix total sous la forme « Total : ... yens ».

④ Déstructure cart pour obtenir le premier produit et affiche-le sous la forme « Premier : nom ».

Éditeur JavaScript / TypeScript

Exécuter le code pour voir le résultat

Retrouver la méthode manquante à partir du résultat — les trois méthodes

Quand l'affichage ou le JSON ne ressemble pas à ce que tu attendais, des lignes comme ${order} ou JSON.stringify(order) ne mentionnent pas le nom de la méthode qu'elles appellent. Pour savoir quelle méthode tu as oublié d'écrire, remonte à partir de la façon dont l'objet est utilisé et de la forme du résultat.

Même sur l'instance d'une classe qui n'a aucune des trois méthodes, chaque syntaxe cherche le nom qui lui est propre le long de la chaîne de prototypes. La seule qu'elle trouve est le toString par défaut d'Object.prototype ; toJSON et [Symbol.iterator] restent introuvables même en remontant jusqu'à null.

// Une classe sans toString, ni 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é filtre", price: 1200, quantity: 2 }]);

// toString est trouvé dans Object.prototype ; sans toJSON, seules les clés propres sont listées
console.log(`${draft}`);              // [object Object]
console.log(JSON.stringify(draft));   // {"id":"A-101"}

// [Symbol.iterator] n'existe nulle part : une erreur est levée
try {
  console.log([...draft]);
} catch (error) {
  console.log(error.message);         // draft is not iterable
}
Ce que donne draft, sans aucune des trois méthodes
${draft}JSON.stringify(draft)[...draft]Cherche toStringCherche toJSONCherche[Symbol.iterator]Trouvé dansObject.prototypeIntrouvable,même jusqu'à nullIntrouvable,même jusqu'à null[object Object]{"id":"A-101"}(pas de total)TypeError: draftis not iterable
Chaque syntaxe cherche un nom différent dans la chaîne. Seul [Symbol.iterator] lève une erreur ; les deux autres renvoient un résultat par défaut.

JSON.stringify ne lève pas d'erreur et renvoie {"id":"A-101"} : des données sans les lignes de #lines ni le total peuvent donc partir sans que personne ne s'en aperçoive avant le destinataire. Le tableau ci-dessous liste d'autres syntaxes qui cherchent les mêmes noms, et ce qui se passe quand la méthode n'est pas écrite.

CodeMéthode cherchéeRésultat sans la méthode
JSON.stringify([draft])toJSON (par élément)[{"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

Vérification des connaissances

Répondez à chaque question une par une.

Question 1Pour draft, issu d'une classe sans aucune des trois méthodes, que se passe-t-il quand tu exécutes const [firstLine] = draft; ?

Question 2Que se passe-t-il si tu appliques JSON.stringify à un tableau de deux commandes dont toJSON renvoie { id: this.id } ?

Question 3Si tu écris deux fois [...cart] pour un cart doté de *[Symbol.iterator](), quelle est la longueur du deuxième tableau ?