Dix règles courantes de JavaScript qui perturbent votre modèle mental
Décrit dix comportements subtils de JavaScript — allant de la mutabilité de const à les closures et à la gestion asynchrone des erreurs — qui provoquent discrètement des bugs dans le code des développeurs expérimentés.
JavaScript cesse d’être intimidant une fois que l’on se familiarise avec sa syntaxe. On ne trébuche plus sur l’absence de points-virgules, les appels asynchrones deviennent monnaie courante, et la différence entre let et const devient quelque chose de naturel. Après avoir développé suffisamment de projets, ce langage commence à ressembler à un vieil ami : on repère d’emblée les erreurs courantes, on a une bonne intuition quant à la manière dont les promesses se résolvent, et on sait que null et undefined ne sont pas la même chose, peu importe à quel point certaines API les confondent négligemment.
Mais la familiarité n’élimine pas toutes les surprises, elle change simplement leur forme. La confusion propre aux débutants est remplacée par des hypothèses plus subtiles et plus dangereuses. Vous savez que les objets sont transmis par référence, pourtant vous oubliez encore qu’une copie superficielle laisse les données imbriquées partagées. Vous savez que les promesses reposent sur des microtâches, mais vous évaluez mal l’ordre d’exécution lorsque plusieurs files de travail commencent à interagir. Vous savez que la coercition existe, pourtant vous manquez encore cette conversion silencieuse cachée au sein d’une comparaison, d’un appel à un tri ou d’une recherche de propriété qui semble inoffensive.
Les parties les plus complexes de JavaScript ne sont que rarement des cas limites exotiques ou des curiosités linguistiques. Il s’agit plutôt de règles ordinaires qui se combinent de manières inattendues. Chaque ligne individuelle semble correcte, est facile à lire et passerait un examen superficiel. La surprise vient du fait que le moteur de exécution assemble ces lignes d’une manière qui ne correspond pas au modèle mental que l’on avait en lisant le code.
Les dix comportements présentés ci-dessous continuent de poser des problèmes aux équipes expérimentées précisément parce qu’ils se cachent à l’intérieur de code qui semble parfaitement normal.
1. const protège le lien, pas l’objet
Une façon courante de simplifier l’explication de const est de dire qu’il « crée une valeur qui ne peut pas changer ». C’est une bonne simplification pour les types primitifs, mais cela ne tient pas compte des tableaux et des objets. Ce que const garantit réellement, c’est que la variable ne peut pas être réaffectée pour pointer vers un autre élément. Il n’est fait mention nulle part du fait que l’élément auquel elle pointe puisse être modifié.
Un objet user déclaré avec const peut toujours acquérir de nouvelles propriétés. Son objet de paramètres imbriqué peut toujours être mis à jour. Un tableau déclaré avec const peut toujours voir des éléments ajoutés, être trié ou vidé. Du point de vue de JavaScript, rien de tout cela n’affecte le lien entre la variable et l’objet : elle fait toujours référence au même objet qu’auparavant.
Cette disparité entre les attentes et la réalité continue de provoquer de véritables bugs dans des bases de code qui reposent fortement sur l’idée d’immutabilité. Un objet de configuration est importé en tant que constante, puis un module le modifie discrètement, et soudainement tous les autres modules qui partagent cette référence perçoivent ce changement. Un tableau conservé dans l’état de l’application est trié sur place, modifiant à la fois la valeur « actuelle » et une valeur plus ancienne que d’autres parties du code considéraient comme un instantané historique figé. Un test modifie un objet de configuration partagé, et un test complètement indépendant commence à échouer, mais uniquement lorsque l’exécuteur de tests exécute les tests dans un ordre différent.
Le mot-clé const donne une fausse sensation de sécurité : il semble constituer un bouclier protecteur autour de la valeur, mais le temps d’exécution ne protège que le pointeur vers la variable, jamais la structure à laquelle il fait référence.
Si vous avez réellement besoin d’immutabilité, vous devez la construire vous-même. Cela peut signifier retourner de nouveaux objets au lieu d’éditer ceux existants, appliquer Object.freeze aux valeurs spécifiques qui comptent, adopter une bibliothèque conçue autour d’un état immuable, ou concevoir vos fonctions de manière à ce qu’elles ne fournissent jamais de références aux données internes mutables dès le départ. Même Object.freeze ne verrouille que le niveau supérieur ; à moins de geler également chaque objet imbriqué, les couches internes restent tout aussi mutables qu’auparavant.
La plupart des développeurs expérimentés peuvent réciter cette règle par cœur, pourtant la surprise refait surface chaque fois que le style visuel du code suggère des garanties plus fortes que celles que JavaScript offre réellement.
2. La copie par étalage d’objets est moins efficace qu’il n’y paraît
Déployer un objet avec { ...obj } est devenu l’un des idiomes JavaScript les plus courants. Sa syntaxe est claire, il est idéal pour combiner des valeurs par défaut avec des modifications, et il permet de créer une version modifiée d’une valeur sans toucher à l’original.
Visuellement, cela ressemble à une copie complète et indépendante. En réalité, la copie ne va qu’à un seul niveau de profondeur.
const original = {
profile: {
name: "Umar",
skills: ["JavaScript", "Node.js"],
},
};
const copy = { ...original };
copy.profile.skills.push("TypeScript");
Lorsque ce fragment de code s’exécute, la compétence nouvellement ajoutée apparaît également dans original.profile.skills. L’objet de niveau supérieur est bien nouveau, mais l’objet profile qui y est imbriqué, ainsi que le tableau skills qui y est lui-même imbriqué, restent exactement les mêmes objets auxquels l’original faisait référence.
C’est ce qui rend cette erreur si persistante : le premier niveau se comporte exactement comme on s’y attend. Vérifier copy !== original renvoie true, ce qui donne l’impression d’une séparation nette. Le problème de mutation partagée ne se manifeste que lorsqu’on va d’un niveau supplémentaire plus bas.
L’utilisation de cette méthode pour fusionner des objets de configuration entraîne également une deuxième surprise. Écrire { ...defaults, ...options } remplace des objets imbriqués entiers au lieu de fusionner leurs clés individuelles. Si options modifie une valeur à l’intérieur d’un objet imbriqué, toutes les valeurs sœurs qui se trouvaient avec elle dans defaults disparaissent également, même si l’appelant n’avait jamais l’intention de les modifier.
La solution n’est pas automatiquement de « cloner en profondeur tout le contenu ». Un clone complet en profondeur peut être coûteux, supprimer des identités d’objets sur lesquelles on compte ailleurs, et dupliquer des données destinées à rester partagées. Ce qui compte vraiment, c’est de déterminer quels chemins imbriqués spécifiques nécessitent des copies indépendantes. Gérez-les explicitement, utilisez un outil approprié pour les mises à jour immuables, ou restructurez les données profondément imbriquées afin que les limites de propriété soient visibles d’un coup d’œil.
L’opérateur de propagation d’objets fonctionne exactement comme annoncé lorsque l’on souhaite réellement une copie superficielle. Il devient alors un problème dès que sa syntaxe concise est confondue avec un clone complet en profondeur.
3. L’égalité ordinaire peut cacher plusieurs conversions
La plupart des développeurs expérimentés privilégient === afin d’éviter les pièges liés à la coercition associés à ==. Cette habitude est vraiment utile, mais elle ne vous protège pas de toutes les conversions implicites effectuées par JavaScript, car de nombreuses autres conversions ont lieu dans des contextes qui n’ont rien à voir avec l’opérateur d’égalité.
Les clés de propriété des objets en sont un bon exemple. À l’exception des symboles, chaque clé d’objet est en réalité une chaîne de caractères. Ainsi, définir object[1] puis lire object["1"] fait référence à la même propriété. Le code qui considère mentalement les identifiants numériques et les chaînes de caractères comme deux entités distinctes peut se retrouver pris au dépourvu dans ce cas.
Les comparaisons relationnelles effectuent leurs propres conversions en fonction de ce qui est comparé. Deux chaînes de caractères sont comparées lexicographiquement (caractère par caractère), tandis que la comparaison d’un nombre avec une chaîne numérique peut déclencher une comparaison numérique. C’est pourquoi "20" < "100" évalue à false, tandis que 20 < "100" évalue à true. En changeant l’origine d’une valeur, la logique de tri ou de validation peut modifier le comportement, même si les valeurs affichées semblent identiques.
L’opérateur + est particulièrement délicat car il sert à la fois à l’addition numérique et à la concaténation de chaînes de caractères, et JavaScript décide lequel utiliser en fonction du contexte. Une seule chaîne de caractères apparaissant tôt dans une série d’opérations d’addition peut changer l’interprétation de tout ce qui suit. Comme les valeurs provenant des champs de formulaire, des paramètres de requête URL et d’autres sources HTML arrivent généralement sous forme de chaînes, une expression qui fonctionnait parfaitement avec des valeurs numériques internes peut passer silencieusement à la concaténation de chaînes dès qu’elle est reliée à des entrées destinées aux utilisateurs.
Les équipes expérimentées évitent ces pièges en normalisant les valeurs dès le départ, plutôt que de compter sur les opérateurs pour interpréter correctement les données brutes en temps réel. Un identifiant est défini une fois pour toutes comme étant soit une chaîne de caractères, soit un nombre. Une somme d’argent est convertie en un type numérique validé. Une date est analysée pour devenir un type temporel approprié ou une chaîne ISO fixe avant toute comparaison.
La coercition en soi n’est pas le véritable problème : les règles de JavaScript à cet égard sont bien définies et cohérentes. Le vrai problème réside dans le fait que ces règles s’appliquent sans cesse là où rien dans le code ne signale visuellement qu’une conversion a lieu.
4. Array.prototype.sort réorganise sur place et compare les textes par défaut
Le tri semble être l’une des opérations les plus simples disponibles sur un tableau, mais il combine en réalité deux comportements qui continuent de priver les gens de compréhension.
La première surprise réside dans le fait qu’il modifie l’original. L’appel à .sort() réorganise le tableau sur lequel il est appliqué et renvoie une référence à ce même tableau. Il est facile de stocker la valeur retournée dans une nouvelle variable en pensant que le tableau d’origine conserve son ordre initial, pour découvrir ensuite que les deux noms font référence à la même liste réorganisée.
La deuxième surprise concerne le fonctionnement des comparaisons en l’absence de fonction de comparaison. Dans ce cas, JavaScript convertit chaque élément en chaîne de caractères et les ordonne lexicographiquement. En essayant cela avec un tableau de nombres, on peut obtenir quelque chose comme 1, 100, 20, 3 au lieu d’un ordre numérique croissant.
Cette combinaison devient dangereuse dans la gestion de l’état au niveau du front-end. Imaginez un composant qui trie un tableau juste avant le rendu, sans se rendre compte que ce tableau est une référence partagée à des données mémorisées ou fournies par le serveur. Il finit ainsi par modifier cette source partagée. Un composant frère qui lit les mêmes données sous-jacentes voit alors également le nouvel ordre, sans jamais avoir appelé lui-même la fonction de tri. La logique de détection des changements et de mémorisation peut également faire défaut dans ce cas, car l’identité du tableau (sa référence) n’a jamais changé, même si son contenu l’a fait — donc une comparaison superficielle ne signalera rien de différent.
Le JavaScript moderne propose des alternatives non mutantes : toSorted() est disponible dans les environnements qui le prennent en charge, et la clonage de l’array avant tri reste la solution standard dans ceux qui ne le permettent pas. Pour un tri numérique, il vous faut toujours fournir à .sort() un comparateur explicite qui encode le type de comparaison souhaité.
La leçon principale est que le nom d’une méthode ne vous indique pas son fonctionnement complet. Le terme « tri » décrit simplement le résultat final, mais ne dit rien sur le fait qu’elle modifie les données en place, sur la manière dont elle convertit les valeurs pour comparaison, sur les garanties de stabilité qu’elle offre, ou encore sur le type de tri spécifique à un domaine qui pourrait être nécessaire. Même les développeurs expérimentés se font avoir lorsqu’une méthode leur semble suffisamment banale pour qu’ils n’aient jamais l’idée de réexaminer ce qu’elle fait réellement en arrière-plan.
5. Un objet Date modélise un instant unique — la plupart des entrées ne correspondent pas clairement à un seul
Les problèmes liés aux fuseaux horaires en JavaScript ne viennent pas vraiment du fait que les fuseaux horaires soient conceptuellement complexes. Ils proviennent d’une courte chaîne de caractères qui contient bien plus d’hypothèses implicites qu’il n’y paraît.
Au fond, un objet Date est une empreinte temporelle : un instant précis mesuré par rapport à UTC. Mais la plupart des dates avec lesquelles les gens travaillent réellement — une date d’anniversaire, une échéance, un cycle de facturation, une réunion programmée — sont des concepts calendaires, et non des points fixes dans le temps universel ; de plus, chacune d’elles se rapporte aux fuseaux horaires différemment.
Si vous analysez une chaîne contenant uniquement une date puis la affichez en utilisant l’heure locale, la date affichée par l’utilisateur peut varier en fonction de son emplacement. Une valeur censée représenter le « 3 août » pourrait être interprétée comme minuit UTC, ce qui, lors de son affichage local ailleurs, apparaîtrait comme le 2 août. De même, une horodatage générée sans indication explicite de décalage UTC peut être interprété différemment en fonction du format exact de la chaîne et du logiciel utilisé pour son analyse.
L’heure d’été ajoute encore une complication. Ajouter un nombre fixe de millisecondes à une Date ne garantit pas qu’il s’agisse du même équivalent qu’ajouter une journée calendaire en heure locale — certains jours, dans les zones où l’heure d’été est en vigueur, durent en réalité vingt-trois ou vingt-cinq heures.
Les développeurs expérimentés rencontrent encore ce problème parce que leur code s’appuie sur un seul type Date pour représenter en même temps plusieurs concepts non liés. Rien dans le système de types ne vous indique si une valeur donnée doit représenter un instant précis, une simple date du calendrier ou une heure locale destinée à être interprétée dans une région spécifique.
Les systèmes robustes résolvent ce problème en rendant l’intention explicite plutôt que implicite. Les instants indiquent un décalage UTC ou sont exprimés directement en UTC. Les valeurs purement calendaires restent telles quelles, sans être inutilement transformées en timestamps complets. Les plannings spécifiques à une région conservent le contexte de fuseau horaire avec lequel ils ont été créés. Le parsing et le formatage se produisent à des limites clairement définies au sein du système, et non là où une date est par hasard affichée ou lue.
Donc, la surprise ne réside généralement pas dans le fait que des fuseaux horaires soient impliqués — c’est plutôt le fait qu’une conversion à première vue anodine ait déjà pris en silence la décision concernant le fuseau approprié.
6. Une promesse peut être résolue avant même que sa fonction de rappel ne s’exécute
Une promesse résolue donne l’impression que l’opération est terminée, mais la fonction associée à elle via .then — ou le code situé après un await — ne s’exécute pas immédiatement au milieu du code synchrone en cours d’exécution. Au lieu de cela, elle est mise en file d’attente dans la file des micro-tâches et s’exécute par la suite.
Cette mise en file d’attente crée un ordre d’exécution qui peut encore prendre de court les développeurs expérimentés lorsque des promesses, des temporisateurs, des gestionnaires d’événements et du code synchrone classique commencent à se mélanger. Une promesse déjà résolue planifie la poursuite de son exécution pour plus tard, tandis que le code synchrone en cours d’exécution continue sans interruption. Cette poursuite mise en file d’attente s’exécutera généralement avant que n’intervienne la fonction de rappel d’un temporisateur prévue pour la tâche suivante — même pour un setTimeout avec un délai de zéro milliseconde.
Le véritable danger pratique n’est pas de deviner l’ordre d’exécution des messages affichés dans la console lors d’un quiz. Il s’agit plutôt de comprendre que « la promesse a été résolue » et « son gestionnaire a réellement été exécuté » représentent deux moments distincts dans le temps. Une mise à jour d’état gérée via un gestionnaire de promesse peut ne pas encore être visible pour le code exécuté plus tard dans cette même pile synchrone. Un test peut vérifier une valeur avant que ses microtâches en attente aient eu le temps d’être traitées. De plus, une chaîne suffisamment longue de microtâches enchaînées peut retarder l’exécution des temporiseurs et du rendu plus que prévu, car le moteur d’exécution traite toujours toute la file de microtâches avant de passer à autre chose.
Le code devient fragile dès qu’il dépend de cet ordre fortuit plutôt que d’une séquence explicite. C’est pourquoi les développeurs expérimentés préfèrent retourner et attendre des promesses lorsque l’un des étapes doit réellement suivre l’autre, s’appuier sur les points d’ancrage du cycle de vie fournis par leur framework, et évitent de considérer un chronomètre à délai nul comme un moyen fiable pour synchroniser des tâches.
Le boucle d’événements elle-même se comporte de manière cohérente — c’est la syntaxe qui fait que plusieurs chronologies distinctes semblent plus proches les unes des autres qu’elles ne le sont réellement. Une promesse peut déjà contenir sa valeur finale tandis que le reste de l’application n’a pas encore pris connaissance de ce fait.
7. Envelopper une appel async dans try/catch ne garantit pas qu’il capturera quoi que ce soit
try/catch entourant une appel de fonction semble destiné à protéger cet appel contre les échecs. Avec les fonctions asynchrones, le fait que cette protection fonctionne réellement dépend entièrement du fait de savoir si l’on attend la promesse retournée.
try {
saveAuditLog(record);
} catch (error) {
reportError(error);
}
Si saveAuditLog lance une exception de manière synchrone avant même de retourner une promesse, le bloc catch s’en chargera sans problème. Mais si saveAuditLog est une fonction asynchrone qui échoue plus tard, au moment où elle rejette la promesse, celle-ci a déjà été renvoyée et l’exécution a déjà quitté le bloc try. La réjection se trouve alors sur cette promesse — elle n’a rien à voir avec l’appel synchrone qui s’est déjà terminé.
L’ajout de await réétablit le lien entre le rejet et le bloc try/catch qui l’entoure, mais cela ne fonctionne que si la fonction que vous écrivez est elle-même autorisée à attendre quelque chose. Rediriger la promesse vers une autre chaîne peut également préserver cette relation d’erreur. Appeler simplement une fonction asynchrone depuis du code de gestion des erreurs indenté, sans l’attendre ni la renvoyer, ne fait ni l’un ni l’autre.
Cette erreur apparaît constamment dans les gestionnaires d’événements, les fonctions de rappel pour tableaux et les hooks de bibliothèques, où l’API environnante ne sait souvent pas quoi faire d’une promesse renvoyée par un callback asynchrone — et l’ignore souvent simplement. Le callback rejette toujours correctement, mais rien n’est en mesure de le gérer. Selon l’environnement d’exécution, cela peut se manifester par un avertissement de rejet non géré, une erreur enregistrée, un plantage du processus, ou par des actions qui ne sont jamais prises en compte silencieusement.
Les développeurs qui ont subi des échecs dus à ces problèmes liés aux promesses plutôt qu’à l’indentation du code demandent quel scope attend réellement le travail asynchrone, où le rejet est transformé en information exploitable, et si certaines appels effectués de manière intentionnelle sans suivi disposent encore d’un moyen réel pour signaler l’échec.
Les erreurs asynchrones se propagent le long des chaînes de promesses, et non en fonction du niveau de nidification de vos accolades.
8. Les valeurs par défaut lors du déstructuration ne s’appliquent qu’à undefined
Les valeurs par défaut lors du déstructuration semblent constituer une protection efficace contre l’absence de données d’entrée.
const { timeout = 5000 } = options;
Cette valeur par défaut n’est appliquée que lorsque timeout est undefined ou simplement absent de l’objet. Elle ne sera pas appliquée pour null, 0, une chaîne vide, ou toute autre valeur ayant été explicitement fournie.
Souvent, c’est précisément le comportement souhaité. Zéro peut être un moyen délibéré d’annuler un retard, et null peut avoir une signification propre distincte. Les problèmes commencent lorsque quelqu’un suppose que la valeur par défaut couvre tout ce qui est « inutilisable ». Une API peut renvoyer null, et le code qui suit tente alors d’effectuer des calculs avec cette valeur. Un champ de formulaire peut être soumis en tant que chaîne vide, de sorte que la valeur par défaut n’est jamais déclenchée. Un chargeur de configuration peut distinguer soigneusement le cas où « la variable n’est pas définie » de celui où « la variable est explicitement définie sur vide », tandis que le code qui utilise cette configuration traite les deux cas de la même manière et recourt à la valeur par défaut.
Les paramètres par défaut des fonctions suivent la même logique : en appelant une fonction sans argument, le paramètre par défaut s’applique, mais si l’on passe explicitement null, il n’est pas utilisé. Cette distinction est très importante lorsque des données sont transmises via des chargements JSON, des lignes de base de données, des soumissions de formulaires ou des API tierces — des contextes où null apparaît fréquemment.
Les développeurs qui s’appuient sur ce schéma ont tendance à traiter les valeurs par défaut et la validation comme des aspects distincts. Une valeur par défaut répond à la question « que se passe-t-il lorsqu’aucune valeur n’est fournie ? », tandis que la validation répond à « ce qui a été fourni est-il réellement acceptable ? ». Fusionner ces deux tâches dans une seule syntaxe pratique peut faire croire que des données incorrectes ont été correctement initialisées.
La règle en vigueur pour ce langage est précise et cohérente. La confusion provient uniquement du mot « default », qui suggère une portée plus large que celle offerte réellement par le comportement strict limité à undefined.
9. Une propriété manquante, une propriété supprimée et undefined sont trois choses différentes
JavaScript permet à une propriété d’objet de contenir la valeur undefined, ou simplement de ne pas exister du tout sur l’objet. Lire l’une ou l’autre directement renvoie undefined, ce qui les fait paraître interchangeables — mais ce n’est pas le cas.
Des outils tels que l’opérateur in, Object.hasOwn, Object.keys, la syntaxe de déploiement, l’itération, les validateurs de schéma et la sérialisation JSON permettent tous de faire cette distinction. Par exemple, JSON.stringify supprime complètement les propriétés dont la valeur est undefined, tandis qu’un élément d’array contenant undefined est traité différemment. La fusion d’objets peut également écraser une valeur existante parfaitement valide par undefined, même lorsque la personne qui effectue la fusion ne voulait que laisser ce champ inchangé.
Cette lacune est une source fréquente de bugs subtils liés aux mises à jour. Un en-tête PATCH côté backend pourrait interpréter un champ omis comme signifiant « ne rien changer » et un champ explicitement défini sur null comme signifiant « effacer ce champ ». Un formulaire côté frontend, quant à lui, pourrait générer undefined pour tout champ que l’utilisateur n’a jamais modifié — mais l’inclusion de ces données dans un objet de mise à jour insère malgré tout ces propriétés undefined, qui écrasent alors les valeurs existantes lors du fusionnement.
Pour éviter cela, définissez intentionnellement les règles de mise à jour plutôt que par hasard. L’omission, undefined, null et les valeurs vides légitimes ne devraient pas acquérir de signification simplement en raison du mécanisme de sérialisation ou de fusion d’objets utilisé.
Cela est particulièrement important en TypeScript, où une propriété optionnelle et une propriété obligatoire dont le type inclut par hasard undefined expriment deux intentions structurellement différentes — même si le code d’application qui les entoure finit par les traiter de la même manière par la suite. Des paramètres de compilateur plus stricts peuvent imposer cette distinction au niveau du type, mais les données réelles qui circulent dans votre application en temps de exécution nécessitent néanmoins leur propre validation.
Deux valeurs qui semblent identiques lorsqu’on les lit peuvent encore représenter des contrats très différents une fois qu’elles traversent une frontière réseau ou lors d’une opération de mise à jour.
10. Une fermeture retient une variable, pas un instantané figé dans le temps
Les fermetures sont l’un des outils les plus puissants que JavaScript met à votre disposition. Une fonction conserve un accès aux variables de son contexte d’initialisation, ce qui permet aux fonctions de rappel, aux fonctions usine, aux gestionnaires d’événements et aux patterns de modules de fonctionner aussi naturellement qu’ils le font.
L’expression concise souvent utilisée est que une fermeture « se souvient d’une valeur ». Une description plus précise est qu’elle conserve un accès à la liaison de la variable. Si la valeur de cette variable change avant que la fermeture ne s’exécute réellement, celle-ci verra la nouvelle valeur — et non celle qui existait au moment de sa création.
C’est là l’explication classique des bugs de boucle liés à var, mais les développeurs plus expérimentés se heurtent souvent à des versions plus subtiles du même problème dans le code asynchrone et la logique d’interface utilisateur. Un callback peut lire un objet de configuration qui a changé après le début de l’opération. Un gestionnaire d’événements peut faire référence à un état désormais obsolète. Une fonction différée peut agir sur l’état actuel d’un objet mutable, alors que le développeur pensait qu’elle utiliserait l’objet tel quel au moment où la fonction avait été planifiée.
Les frameworks ajoutent leurs propres règles de cycle de vie par-dessus celles-ci, ce qui a tendance à rendre l’effet plus visible. Dans React, par exemple, une fonction de rappel créée lors d’un rendu particulier conserve les valeurs existantes à ce moment-là. Cela peut provoquer un comportement semblable à celui d’un état obsolète, même si la fermeture JavaScript sous-jacente fonctionne exactement comme prévu. La surprise vient du fait que l’on s’attend à ce que la fonction de rappel parvienne d’une manière ou d’une autre à obtenir automatiquement l’état le plus récent — or ce n’est pas ainsi que fonctionnent les fermetures.
La solution appropriée dépend de ce dont vous avez réellement besoin. Parfois, vous souhaitez vraiment conserver la valeur telle qu’elle était au moment où l’opération a été lancée ; dans ce cas, il est judicieux de capturer un instantané stable au préalable. D’autres fois, vous voulez la valeur la plus récente disponible, ce qui nécessite une référence actuelle similaire à un ref ou une mise à jour fonctionnelle. Et parfois, la bonne solution consiste à faire en sorte que les dépendances changeantes recréent complètement le callback.
La question utile à se poser est de savoir si le travail différé doit refléter l’état du monde au moment où il a été planifié, ou bien l’état du monde au moment où il s’exécute réellement. La clôture elle-même n’a pas d’opinion à ce sujet — elle conserve fidèlement toute relation que votre code a établie, même lorsque cette relation n’était pas celle que vous aviez l’intention de créer.
JavaScript se comporte de manière cohérente — nos raccourcis mentaux, non
Aucun des comportements décrits ici n’est une particularité arbitraire. const verrouille un lien, et non la valeur qu’il contient. La fonction de réplication ne crée des copies qu’à un seul niveau de profondeur. Le tri par défaut des tableaux convertit d’abord les éléments en chaînes de caractères. Les gestionnaires de Promise s’exécutent en tant que microtâches. La déstructuration par défaut répond spécifiquement à undefined. Les clôtures conservent les liens lexicaux plutôt que des valeurs fixes. Chacune de ces règles est appliquée par le langage avec une cohérence totale.
Les surprises surviennent lorsque les développeurs s’appuient sur des modèles mentaux plus simplistes que ce que le moteur d’exécution impose réellement. On dit qu’une constante « ne peut pas changer », qu’une fonction de réplication « crée une copie », qu’un appel asynchrone « se trouve à l’intérieur de try/catch », ou qu’une clôture « se souvient d’une valeur ». Ces simplifications sont utiles tant que de nouvelles exigences ne dépendent pas précisément des détails qu’elles ont omis.
Ce qui protège les développeurs expérimentés, ce n’est pas la mémorisation d’une liste toujours croissante de faits anecdotiques — c’est l’habitude de rendre explicites les contrats chaque fois que des données franchissent une frontière. Cela signifie normaliser les valeurs provenant du monde extérieur, traiter « absent » et « invalide » comme des concepts distincts, éviter toute mutation accidentelle de références partagées, être clair quant à celui qui est responsable du traitement des erreurs dans une opération asynchrone, préserver le sens voulu des timestamps et des fuseaux horaires, et décider à l’avance si un callback différé doit s’appuyer sur l’état ancien ou l’état actuel.
Rédiger du JavaScript fiable ne consiste pas à éviter toutes les fonctionnalités flexibles offertes par le langage. Il s’agit plutôt d’utiliser ces fonctionnalités sans supposer que leur syntaxe concise promet plus qu’elle ne le permet réellement.
JavaScript continue de surprendre les développeurs expérimentés précisément parce que l’expérience engendre une confiance envers du code qui semble familier. Le risque est que des syntaxes à l’aspect familier puissent encore cacher discrètement des décisions concernant les références, les conversions de types, le moment d’exécution et la responsabilité en cas d’erreur — des décisions qui ne deviennent visibles que lorsque quelque chose d’autre dans le système change autour d’elles.
Le langage, presque toujours, fait exactement ce à quoi on lui a donné pour instruction de faire.
La surprise réside dans la prise de conscience de ce que votre code lui a en réalité demandé de faire.
Lectures complémentaires
- Les pièges courants de JavaScript et TypeScript qui brisent silencieusement le code — Explique les subtilités et pièges de JavaScript et TypeScript, allant des comparaisons avec NaN aux problèmes liés au timing asynchrone et à la coercition de types, qui causent des bugs bien que le code paraisse correct.