Accueil / Articles / Idées reçues courantes sur async/await qui causent des bugs en production

Idées reçues courantes sur async/await qui causent des bugs en production

Explique neuf malentendus subtils concernant async/await — allant des conditions de concurrence aux rejets non gérés — qui endommagent silencieusement les applications JavaScript en situation réelle.

3620 mots

Pendant longtemps, de nombreux développeurs ont cru maîtriser parfaitement async/await simplement parce qu’ils l’utilisaient avec aisance. Savoir qu’une fonction async renvoie toujours une promesse, savoir placer await avant une appel vers le réseau, et comprendre que try/catch permet de gérer plus facilement les erreurs asynchrones suffisent parfois à leur semblable. Par rapport aux codes basés sur beaucoup de callbacks, ce style paraît ordonné et se lit presque comme du JavaScript synchrone classique : obtenir les informations de l’utilisateur, charger son compte, mettre à jour l’interface utilisateur, et gérer les problèmes qui surviennent en cours de route. Sa syntaxe est si intuitive qu’il est facile de ne jamais s’arrêter pour examiner le modèle mental qui la sous-tend.

Le véritable problème est que cette aisance superficielle couvre seulement la forme de async/await, sans aborder les règles fondamentales liées à la gestion asynchrone. Il est courant de considérer await comme s’il figeait l’ensemble du programme, d’imaginer que l’appel à une fonction asynchrone lie automatiquement son résultat à celui qui l’a appelée, et de croire qu’un code écrit dans un style linéaire, du haut vers le bas, ne pourrait pas entrer en concurrence avec lui-même. Ces hypothèses causent rarement de dommages visibles dans de petites démonstrations, car une seule opération s’exécute à la fois, les réponses reviennent rapidement et les données simulées se résolvent dans un ordre prévisible. Les systèmes de production réels sont bien moins indulgents.

Les bugs qui finissent par apparaître ne ressemblent pas aux erreurs asynchrones typiques décrites dans les manuels. Une fonction renvoie avant que certains effets secondaires ne soient réellement terminés. Une requête obsolète écrase l’état défini par une requête plus récente. Un bloc try/catch permet à une erreur de passer inaperçue. Un job en lot lance plus d’opérations simultanées que le système ne peut réellement gérer. Chaque promesse, examinée individuellement, semble parfaite — pourtant le flux de travail dans son ensemble ne se comporte pas du tout comme prévu à l’origine.

Il peut falloir beaucoup de temps pour bien assimiler cette leçon : async/await ne supprime pas la concurrence en JavaScript. Tout ce qu’il fait, c’est offrir à une fonction asynchrone un moyen plus lisible de s’arrêter temporairement puis de reprendre. Tout le reste dépend toujours des promesses qui sont renvoyées, celles qui attendent d’être exécutées, celles qui sont ignorées silencieusement, celles qui sont annulées ou réessayées, ainsi que de celles qui ont la permission de modifier l’état partagé au cours du processus.

Je pensais que await mettait en pause plus d’une fonction

Une première idée fausse courante est assez simple. Chaque fois que await apparaît, on a tendance à croire qu’il met en pause tout ce qui l’entoure. JavaScript ne bloque jamais l’onglet du navigateur ni le processus Node.js, mais il est facile de supposer que la logique environnante attendra son tour d’une manière ou d’une autre.

C’est pas comme ça que ça fonctionne. await ne suspend que la fonction asynchrone spécifique à l’intérieur de laquelle il se trouve. Le contrôle revient au moteur d’exécution tant que la promesse attendue est encore en attente, et JavaScript continue de gérer tout ce qui est prêt à être exécuté. Les écouteurs d’événements peuvent se déclencher, les compteurs à retardement peuvent sonner, des requêtes non liées peuvent être résolues, et même une autre appel à cette même fonction peut commencer entre-temps.

async function loadProfile() {
  console.log("Loading profile");

const profile = await fetchProfile();
  console.log("Profile loaded");
  return profile;
}
console.log("Before");
loadProfile();
console.log("After");

Rien dans le code externe ne s’arrête simplement parce que loadProfile contient un await. L’appel à cette fonction la lance immédiatement et renvoie une promesse. À moins que celui qui l’a appelée ne choisisse d’attendre ou de renvoyer cette promesse, l’exécution continue sans s’arrêter.

Cela peut sembler être une petite question technique, mais ses conséquences vont bien au-delà de simples erreurs d’ordre dans les journaux de console. Un gestionnaire de requêtes peut envoyer sa réponse tandis qu’une écriture dans le journal d’audit est encore en cours. Un outil CLI peut se terminer alors qu’une écriture de fichier est toujours en attente. Un test peut indiquer un succès avant même que les assertions contenues dans une fonction de rappel asynchrone ne soient exécutées. La fonction asynchrone se comporte exactement comme prévu — le véritable problème est que l’appelant n’a jamais lié son propre achèvement à celui de l’appel asynchrone.

Ainsi, la question pertinente n’est pas de savoir si une fonction utilise internelement await. Il s’agit plutôt de savoir si la promesse représentant l’opération complète est bien connectée au code qui a besoin de savoir quand elle est terminée.

J’ai appelé des fonctions asynchrones sans déterminer qui serait responsable de leur achèvement

Une erreur qui apparaît constamment est l’exécution d’une fonction asynchrone sans attendre son résultat ou sans le renvoyer. Parfois, il s’agit d’une simple négligence. D’autres fois, la tâche semble suffisamment peu importante pour être exécutée en arrière-plan.

Le véritable problème n’est pas nécessairement le fait que la tâche s’exécute de manière indépendante — c’est plutôt qu’aucun responsable n’a été désigné pour gérer son résultat.

Imaginez qu’une commande soit enregistrée, suivie d’envoi d’une notification. Si cette notification doit impérativement réussir pour que toute l’opération soit considérée comme terminée, celui qui a lancé la requête doit l’attendre. Si c’est vraiment optionnel, il peut être acceptable de la laisser s’exécuter seule, mais en cas d’échec, il faut quand même un endroit où diriger ce résultat. Le simple fait d’ignorer la promesse renvoyée ne transforme pas magiquement cette requête en tâche en arrière-plan fiable — cela prive simplement celui qui a lancé la requête de tout moyen de savoir ce qui s’est passé par la suite.

Cela devient particulièrement risqué dans le code backend. Une fonction peut renvoyer une réponse réussie même si un effet secondaire asynchrone ultérieur a échoué. L’utilisateur pensera que l’action a réussi, tandis que le système ne dispose d’aucun enregistrement fiable indiquant qu’un travail inachevé persiste. Si le processus redémarre justement à ce moment-là, ce travail en attente disparaît simplement.

Il est utile de considérer chaque promesse non attendue comme un choix architectural plutôt que comme une solution improvisée. Soit l’opération en cours est responsable du résultat et doit y attendre, soit une autre couche en est responsable et a besoin d’un mécanisme adéquat pour suivre l’achèvement, les tentatives de répétition et les échecs. Il n’y a rien de mal à avoir des tâches en arrière-plan véritablement intentionnelles — mais elles ne devraient pas apparaître simplement parce que quelqu’un a oublié d’écrire await.

Une promesse que personne ne surveille n’est pas, par conception, une tâche en arrière-plan. C’est simplement du travail sans propriétaire.

Considérer forEach comme s’il comprenait les promesses

L’un des premiers schémas asynchrones qui viennent perturber discrètement le modèle mental habituel est l’utilisation de forEach avec une fonction de rappel asynchrone. La syntaxe semble tout à fait raisonnable, ce qui rend facile de ne pas comprendre pourquoi la fonction conteneuse se termine avant même que le travail réel n’ait eu lieu.

async function saveUsers(users) {
  users.forEach(async (user) => {
    await saveUser(user);
  });

  console.log("All users saved");
}

forEach exécute la fonction de rappel mais ignore ce qu’elle retourne. Une fonction de rappel asynchrone retourne toujours une promesse, de sorte que chaque appel génère discrètement une promesse à laquelle personne ne garde de référence. Comme il n’y a plus rien à attendre, la fonction externe se résout immédiatement, quel que soit le travail encore en cours dans ses appels internes.

Ce n’est pas vraiment un bug spécifique à forEach. Il provient de l’idée erronée que le fait de passer une fonction asynchrone à n’importe quelle méthode d’array améliore automatiquement son comportement pour qu’elle comprenne les promesses. Ce n’est pas le cas. Un outil d’itération synchrone reste synchrone quel que soit le type de fonction qui lui est passé, à moins que cet outil n’ait été conçu spécifiquement pour coordonner des tâches asynchrones.

Le même problème se pose avec map, filter et reduce. L’utilisation de map avec une fonction de rappel asynchrone renvoie un tableau rempli de promesses en attente, et non un tableau de valeurs finales. filter ne patiente jamais en attendant le résultat d’un prédicat asynchrone ; il évalue donc les objets promesse eux-mêmes — qui sont toujours vrais — au lieu des résultats qu’ils produiront finalement. Il est possible de créer une version asynchrone de reduce qui fonctionne correctement, mais elle est souvent difficile à suivre, car l’accumulateur lui-même est une promesse qu’il faut déballer avec soin à chaque itération.

Lorsque cela devient clair, écrire du code correct ne consiste plus à mémoriser quelles méthodes d’array sont « autorisées », mais plutôt à choisir le modèle d’exécution réellement requis par la tâche. Lorsque chaque étape dépend véritablement de l’achèvement de la précédente, une boucle for...of contenant await exprime directement cette intention. Lorsque les étapes sont indépendantes et peuvent se dérouler en même temps, il est souvent judicieux de les transformer en un tableau de promesses et d’attendre leur exécution simultanée à l’aide de Promise.all. Et lorsque la concurrence doit être limitée, ni une boucle simple ni un Promise.all sans limite ne suffisent.

La syntaxe doit découler du besoin opérationnel, et non l’inverse. Il est tentant de choisir d’abord le schéma qui semble le plus idiomatique en espérant que le comportement souhaité en découlera, mais cet ordre d’opérations est inversé.

En supposant que Promise.all rendait les choses plus rapides sans risque

Après avoir découvert que forEach ne patiente jamais, la prochaine étape logique est d’utiliser Promise.all, qui constitue généralement l’outil adapté : transformer les éléments en opérations asynchrones, transmettre les promesses résultantes à Promise.all, et attendre que l’ensemble se résolve en même temps.

Cette approche fonctionne bien lorsque les opérations individuelles ne dépendent pas les unes des autres, que la taille du lot reste raisonnable, et qu’une seule erreur doit invalider l’ensemble des résultats. Les problèmes apparaissent lorsque cet outil est utilisé en dehors de ces limites.

Promise.all ne limite pas automatiquement quoi que ce soit. La majeure partie du travail de fond débute dès la création des promesses, ce qui signifie que le parcours d’un petit millier de records peut déclencher presque simultanément plusieurs milliers de requêtes de base de données ou de demandes externes. Un tel code peut sembler fonctionner correctement avec un petit ensemble de données local, mais il peut ensuite saturer les pools de connexions, atteindre des limites de fréquence ou épuiser la mémoire lorsqu’il est confronté à des données à l’échelle de production.

Sa gestion des erreurs peut également surprendre. Lorsqu’une promesse du groupe échoue, les autres ne s’arrêtent pas automatiquement. Elles continuent de fonctionner, ce qui signifie qu’elles peuvent encore enregistrer des données, envoyer des messages ou modifier un état externe même après que le code appelant soit déjà entré dans le bloc catch. Affirmer que « le lot a échoué » est techniquement exact, mais cela ne veut pas dire qu’aucun effet secondaire n’a eu lieu.

En tentant à nouveau l’ensemble du lot par la suite, on peut refaire le travail qui a déjà réussi lors de la première tentative. À ce stade, il ne s’agit plus de la mécanique des promesses — mais d’idempotence et de gestion des terminaisons partielles.

Au lieu de recourir par défaut à Promise.all, il est utile de se poser un autre ensemble de questions : combien d’opérations peut-on effectuer en même temps sans risque ? Sont-elles vraiment indépendantes les unes des autres ? Quelle doit être la conséquence d’une échec pour le reste du lot ? Existe-t-il un moyen d’arrêter les tâches qui ont déjà commencé ? En cas de tentative de réessai, les éléments déjà terminés pourraient-ils être dupliqués ? Le demandeur a-t-il réellement besoin de chaque résultat, ou un succès partiel honnête serait-il acceptable ?

Parfois, Promise.all reste la solution appropriée. D’autres fois, Promise.allSettled convient mieux. À d’autres occasions encore, la situation exige un boucle séquentielle, un limiteur de concurrence, une file d’attente ou une transaction englobant toute l’opération. La solution correcte dépend des garanties que l’opération doit assurer, et non de la vitesse à laquelle le code semble fonctionner au premier abord.

Présumer que du code lu de haut en bas ne peut pas présenter de conditions concurrentes

Peut-être est-ce le malentendu le plus coûteux : croire que await protège le code des conditions concurrentes. À l’intérieur d’une même fonction, les instructions suivant un await se poursuivent bien dans l’ordre, ce qui donne l’impression que la fonction constitue une séquence continue. Mais lors de plusieurs appels concurrents à cette même fonction, plusieurs exécutions peuvent facilement se chevaucher.

Imaginez deux requêtes qui lisent chacune un solde, calculent un nouveau solde et le sauvegardent. Les deux requêtes attendent leur opération de lecture, ainsi que leur opération d’écriture. Chaque ligne à l’intérieur de chaque appel s’exécute dans l’ordre attendu. La condition de course apparaît néanmoins, car les deux requêtes peuvent lire le même solde de départ avant que l’une d’elles n’ait écrit sa mise à jour.

Le même type de bug se produit sur le frontend. Une recherche est lancée pour une requête plus ancienne, puis une deuxième recherche pour une requête plus récente. La requête la plus récente se termine en premier et met à jour correctement l’interface. La requête plus ancienne s’exécute par la suite et écrase cet état correct par un résultat obsolète. Chaque appel a attendu son propre chargement exactement comme prévu — ce qui manque, c’est une règle indiquant quelle requête a encore l’autorisation de mettre à jour l’interface.

Ce point doit être gardé à l’esprit pour tout await situé entre la lecture d’un état et l’action qui y est effectuée. Cette pause n’est pas simplement une interruption temporaire inoffensive ; c’est une fenêtre pendant laquelle d’autres tâches peuvent s’exécuter et invalider les hypothèses faites par la fonction juste avant cette pause.

Les vérifications effectuées au niveau de l’application ne survivent pas automatiquement à cette fenêtre. Vérifier que un nom d’utilisateur est libre avant de l’insérer ne protège pas contre deux requêtes effectuant la même vérification presque en même temps. S’assurer qu’un enregistrement est encore en attente avant de l’approuver ne garantit pas qu’aucun autre processus n’a pas modifié son état entre-temps.

La protection réelle doit généralement provenir d’un niveau inférieur : une contrainte de unicité au niveau de la base de données, une mise à jour conditionnelle, un verrouillage optimiste, une clé d’idempotence, une transaction, ou des règles de propriété explicites appliquées dans l’interface utilisateur. await ne gère que l’achèvement d’une promesse spécifique. Il ne fait rien pour verrouiller l’état partagé ni pour garantir qu’une valeur lue précédemment reste valable au moment où elle est traitée.

Attendre que try/catch capture des opérations qui n’ont jamais été attendues

Une autre erreur courante semble tout à fait sûre lors de la revue du code. Une appel asynchrone se trouve à l’intérieur d’un bloc try/catch, et il semble raisonnable de penser que toute réjection sera gérée là.

try {
  sendAnalyticsEvent(event);
} catch (error) {
  logError(error);
}

Lorsque sendAnalyticsEvent est une fonction asynchrone qui rejette sa promesse après l’avoir déjà renvoyée, le bloc catch adjacent ne perçoit jamais cette erreur. La partie synchrone de l’appel a réussi dès qu’elle a restitué une promesse. Le rejet a lieu par la suite, en dehors du cadre d’exécution que surveille le bloc try.

try {
  await sendAnalyticsEvent(event);
} catch (error) {
  logError(error);
}

Exprimé simplement, la différence semble évidente, mais la version précédente paraît sûre précisément parce que l’appel asynchrone se trouve visuellement à l’intérieur d’un gestionnaire d’erreurs. La disposition suggère une relation que le code ne crée en réalité jamais.

Cette même lacune se manifeste au sein des appels en retour et des gestionnaires d’événements. Un appel en retour asynchrone peut être rejeté en interne, mais si le code qui l’appelle ne passe jamais en attente ni n’examine la promesse qu’il renvoie, ce rejet n’a nulle part où aller. Envelopper le point d’appel dans un try/catch ne sert à rien, car le rejet appartient à une promesse que rien dans le contexte ne surveille.

La règle fondamentale est que les erreurs dans le code asynchrone se transmettent via des promesses, et non grâce à l’indentation. Pour capturer un rejet, il faut attendre directement la promesse, la renvoyer dans une chaîne qui dispose déjà d’un gestionnaire, ou ajouter un appel en retour explicite pour gérer le rejet. Si l’on jette la promesse, son chemin d’erreur est également éliminé.

Cela concerne également les blocs de capture qui absorbent trop d’erreurs. Transformer chaque échec en une valeur par défaut null crée un problème distinct : les appels ne peuvent plus distinguer une véritable erreur d’un résultat légitimement vide. Il ne s’agit pas simplement de capturer l’erreur ; le code doit préserver la signification de cette erreur pour celui qui la traitera ensuite.

Confondre l’annulation avec l’inversion

Lorsqu’une équipe adopte pour la première fois AbortController, il est tentant de penser que l’annulation d’une requête signifie que le travail en cours s’est réellement arrêté. Pour les requêtes effectuées depuis le navigateur, l’annulation empêche bien le client de continuer à attendre et évite souvent des traitements supplémentaires inutiles sur le client lui-même. Cela est véritablement utile pour des fonctionnalités comme la recherche en temps réel, le départ d’une page ou l’abandon d’une lecture devenue obsolète.

Cela ne garantit pas pour autant que les opérations déjà transmises au serveur soient annulées.

Si une demande d’écriture est interrompue après que le serveur l’a déjà reçue, le système backend peut néanmoins continuer à finaliser la mise à jour de la base de données ou l’appel externe. Tout ce que le client sait, c’est qu’il a cessé d’attendre une réponse. Tenter à nouveau cette demande sans aucune forme de protection idempotente comporte le risque de répéter une opération qui a déjà réussi du côté du serveur.

Cette distinction existe parce que l’annulation côté client et l’état du serveur se situent de part et d’autre d’une frontière réseau. Le client peut renoncer à obtenir un résultat, mais il n’a aucun moyen de franchir cette frontière pour annuler des effets qui ont déjà été appliqués.

La suppression doit être considérée avant tout comme un signal indiquant l’importance d’un résultat plutôt que comme une garantie concernant son état. Elle indique au reste de l’application que l’utilisateur ne s’intéresse plus à un résultat particulier, de sorte que ce dernier ne devrait pas pouvoir écraser l’état actuel. Pour les opérations de lecture, annuler la requête constitue une optimisation raisonnable. Pour les écritures, un mécanisme distinct est encore nécessaire pour savoir si l’opération est terminée, en cours d’exécution, ou peut être réessayée sans dupliquer son effet.

La suppression permet de déterminer si un résultat est toujours souhaité. Elle ne permet pas de savoir si l’action sous-jacente reste cohérente.

Écrire la syntaxe asynchrone avant de définir le flux de travail

Le point commun à tous ces erreurs est de traiter la conception asynchrone comme s’il s’agissait purement d’une question de syntaxe, en se demandant si l’on doit utiliser await, Promise.all ou un callback asynchrone avant de déterminer quelles garanties l’opération doit réellement fournir.

Les questions les plus utiles se posent plutôt en amont. Le appelant a-t-il besoin d’attendre la fin de cette opération avant de passer à autre chose ? Plusieurs tâches peuvent-elles s’exécuter en même temps sans risque ? Leur ordre relatif a-t-il de l’importance ? Quelle est la réponse appropriée lorsque certaines tâches réussissent et d’autres non ? Est-il sûr de réessayer cette opération ? Qui est responsable du traitement des erreurs ? Un résultat obsolète pourrait-il encore écraser un état partagé ? Et lorsque quelque chose expire, cela signifie-t-il que l’opération a échoué, ou simplement que cet appelant en particulier a abandonné l’attente ?

Lorsque ces questions ont été répondues, le JavaScript lui-même s’installe généralement sans grande difficulté.

Dans une séquence où l’ordre est vraiment important, on peut utiliser une boucle qui attend chaque étape à son tour. Les tâches indépendantes ayant un nombre limité et connu peuvent être exécutées en parallèle. De plus grands lots peuvent être traités avec une limite de concurrence. Les tâches qui n’ont pas besoin d’empêcher l’appelant de continuer peuvent être transférées dans une file durable. Les mises à jour de l’état partagé peuvent être contrôlées par des vérifications explicites d’appartenance. Les écritures dans la base de données peuvent appliquer leurs invariants de manière atomique au niveau du stockage. Les opérations pour lesquelles une duplication serait coûteuse peuvent utiliser des clés d’idempotence afin qu’un redémarrage ne crée pas une deuxième copie du même effet.

La partie difficile réside toujours dans le choix correct de l’emplacement du await. Il s’agit de déterminer ce que signifie réellement « terminé » à chaque étape franchie par l’opération.

Connaître le mot-clé mais pas le contrat

Se tromper dans l’utilisation de async/await pendant des années tient moins au fait d’oublier le fonctionnement de la syntaxe, et plus au fait que cela donne l’impression que le code asynchrone est bien plus séquentiel, local et prévisible qu’il ne l’est en réalité.

Voir un await amène à penser que tout ce qui l’entoure est également suspendu. Il est facile d’oublier qu’il est possible d’appeler une fonction asynchrone sans déterminer qui sera responsable de son exécution finale. L’utilisation de méthodes d’array synchrones avec des callbacks asynchrones suppose que ces deux éléments se comprennent mutuellement, ce qui n’est pas le cas. Recourir à Promise.all sans réfléchir au chargement ou à ce qui se passe lorsque seules certaines promesses réussissent est une habitude courante mais risquée. Faire confiance à un code lu de haut en bas, même lorsque plusieurs appels peuvent réellement se chevaucher dans le temps, cache les conflits d’exécution sous nos yeux. S’attendre à ce que try/catch capture les rejets de promesses qui n’ont jamais été attendues, ou confondre une demande annulée avec la preuve qu’aucun événement n’a eu lieu sur le serveur, provient tous deux du même manque de vigilance.

Chacune de ces erreurs trouve son origine dans le même manque : celui qui existe entre l’apparence du code et ce qu’il promet réellement.

Comprendre correctement le code asynchrone signifie suivre les promesses à travers les frontières des fonctions plutôt que de se contenter de lire du haut vers le bas. Cela implique de vérifier qui les crée, qui les attend, qui est responsable du traitement des échecs, et quel élément d’état conserve encore son autorité sur le résultat une fois tout résolu. Cela signifie également prêter une grande attention à chaque point où await provoque une pause, car c’est précisément à ce moment que d’autres opérations peuvent modifier les hypothèses sur lesquelles la fonction s’appuyait.

Le code peut encore sembler séquentiel sur la page. Cette apparence ne signifie plus pour autant que le système se comporte de cette manière.

Cette évolution transforme le JavaScript asynchrone d’un ensemble de mots-clés en un modèle basé sur la propriété, le timing et les pannes. Elle explique également pourquoi du code qui semblait correct pendant des années peut encore se comporter de manière anormale en production, même s’il passe tous les tests.

Apprendre à attendre une promesse est la partie facile.

Comprendre ce que fait le reste du programme pendant cette attente prend beaucoup plus de temps.

Lectures complémentaires

  • Dangers communs de JavaScript et TypeScript qui détruisent le code en secret — Explique les pièges subtils de JavaScript et TypeScript, allant des comparaisons avec NaN aux problèmes de synchronisation asynchrone et à la coercition de types, qui provoquent des bugs bien que tout semble correct.
  • Fausses conceptions communes de Node.js et des bases de données qui causent des bugs en production — Découvrez pourquoi async/await, le pooling de connexions et les ORM ne préviennent pas automatiquement les conditions de course, l’épuisement des connexions ou les injections SQL dans les applications Node.js.
  • Neuf patterns de Promise pour un JavaScript asynchrone fiable en production — Découvrez des patterns pratiques de Promise — requêtes parallèles, délais d’expiration, tentatives répétées, limites de concurrence et annulation — pour créer du JavaScript asynchrone résilient, adapté à la production.
  • Corriger les problèmes de gestion des erreurs Async/Await dans le code production Node.js — Apprenez cinq erreurs courantes de gestion des erreurs avec async/await en JavaScript et Node.js qui provoquent des échecs silencieux et des conditions de course, ainsi que des solutions concrètes.
  • Sept idiomes communs de JavaScript qui introduisent subtilement des problèmes futurs — Explique comment les schémas courants de JavaScript tels que les vérifications de vérité, la chaînage optionnel et la syntaxe de déploiement cachent des hypothèses qui se brisent silencieusement à mesure que le code évolue.