Accueil / Articles

Sept idiomes JavaScript courants qui introduisent discrètement des bugs à venir

Explique comment des patterns 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 au fil de l’évolution du code.

Éditeur : REACTAPP.TOP14 min de lecture3286 mots

Sources: 7 JavaScript Anti-Patterns That Cause Future Bugs

Puis, des mois plus tard, le code commence à accepter 0 comme valeur de configuration valide. Une réponse API cesse d’inclure un champ qui était auparavant toujours présent. Une tâche en arrière-plan qui gérait autrefois des dizaines d’enregistrements doit soudainement en traiter des milliers. Un objet imbriqué est réutilisé à un endroit où l’on pensait qu’il avait déjà été copié. Aucun de ces événements ultérieurs ne semble lié à la refonte précédente, mais chacun révèle une hypothèse que le code compact avait intégrée sans l’indiquer explicitement.

C’est précisément pour cette raison que certains idiomes JavaScript sont risqués. Ils semblent rarement erronés au moment où ils sont utilisés, et les développeurs expérimentés les emploient justement parce qu’ils sont pratiques, expressifs et précis dans les conditions actuelles. Les problèmes commencent lorsque cette praticité fusionne discrètement plusieurs états distincts en un seul comportement, ou lorsque une syntaxe qui donne l’impression d’une garantie de sécurité n’est en réalité qu’un abrégé.

Ainsi, la vraie question lors de l’examen n’est pas de savoir si un morceau de code est ingénieux ou concis. Il s’agit plutôt de savoir si le code expose clairement ses hypothèses, afin que les modifications futures provoquent une erreur évidente plutôt qu’un résultat incorrect silencieux.

1. Les vérifications de vérité fusionnent différentes valeurs en une même décision

Imaginez un paramètre de configuration qui contrôle le nombre de tentatives autorisées pour une opération.

function getRetryCount(config) {
  if (!config.retries) {
    return 3;
  }

  return config.retries;
}

Cette logique fonctionne tant que retries est soit un nombre positif, soit absent. Mais dès qu’on ajoute la possibilité de désactiver complètement les tentatives en mettant cette valeur à 0, la fonction ignore ce choix explicite et renvoie plutôt 3. Les règles de vérité de JavaScript sont respectées correctement — c’est simplement que ces règles ne correspondent plus aux besoins réels de l’application.

Diverses valeurs autres que false sont considérées comme fausses en JavaScript : 0, "", null, undefined et NaN en font tous partie. Ainsi, une condition comme if (!value) ne vérifie pas vraiment s’il manque quelque chose — elle vérifie plutôt si JavaScript le convertirait en false, ce qui est une question bien plus large que « est-ce absent ? »

Une version plus précise dépend du contenu réel du contrat.

function getRetryCount(config) {
  if (config.retries === undefined) {
    return 3;
  }

  return config.retries;
}

Lorsque tant null que undefined sont censés indiquer « rien n’a été fourni », le coalescing nullish peut exprimer cette intention de manière plus claire.

const retries = config.retries ?? 3;

Cette distinction est très importante pour les champs de formulaire, les paramètres de pagination, les prix, les limites, les indicateurs fonctionnels, les compteurs et autres paramètres numériques. Un indicateur nommé enabled peut légitimement avoir la valeur false. Un champ de recherche peut légitimement contenir une chaîne vide. Un offset peut légitimement être égal à 0. Considérer chaque valeur fausse comme « non fournie » entraîne la perte d’informations au profit de la concision.

Rien de tout cela ne rend les vérifications de vérité intrinsèquement erronées. Elles fonctionnent bien lorsque l’exigence concerne réellement la vérité — par exemple, pour décider de rendre une chaîne de caractères optionnelle uniquement si elle n’est pas vide. Elles deviennent un problème dès que le domaine comporte plus d’états distincts que la coercition booléenne de JavaScript ne peut en gérer.

La question à se poser lors de l’évaluation est simple : cette condition a-t-elle réellement besoin de vérifier la fausseté, ou la vérité est-elle utilisée comme substitut à une règle métier plus spécifique ?

2. La chaînage optionnel peut transformer des données défectueuses en données manquantes silencieusement

Le chaînage optionnel compte parmi les ajouts les plus précieux de JavaScript, car il rend la navigation dans des structures fortement optionnelles bien moins difficile.

const avatarUrl = user.profile?.avatar?.url;

Lorsque profile ou avatar peuvent réellement être absents, c’est précisément pour cette situation que l’encachement optionnel existe. Obtenir undefined est pertinent ici, car l’absence constitue un résultat normal et attendu.

Les problèmes commencent lorsque l’encachement optionnel est appliqué à des données qui devraient toujours être présentes.

function getShippingCountry(order) {
  return order.customer?.address?.country;
}

Supposons que le contrat du système garantisse qu’une commande pouvant être expédiée inclut toujours un client et une adresse de livraison. Si un bug en amont provoque l’apparition d’une commande manquant de l’un de ces éléments, l’encachement optionnel transforme silencieusement ce qui devrait être une violation du contrat en un simple undefined. Ce problème ne se manifestera peut-être que bien plus tard, sous forme d’un calcul fiscal incorrect, d’une étiquette de livraison manquante ou d’un comportement étrange dans une partie du codebase sans rapport apparent.

Une approche plus appropriée dans ce cas pourrait être d’échouer immédiatement, au moment même où l’hypothèse devient cruciale.

function getShippingCountry(order) {
  if (!order.customer?.address) {
    throw new Error(
      `Order ${order.id} is missing a required shipping address`
    );
  }

  return order.customer.address.country;
}

Cette version est plus verbale, mais elle communique davantage. Elle indique à quiconque la lit par la suite que l’adresse n’est pas simplement une donnée optionnelle qui peut apparaître ou non — son absence signifie que l’objet ne doit absolument pas être considéré comme valide pour cette opération.

La chaînage optionnel a également tendance à se propager dès que l’incertitude pénètre dans un type ou un contrat API. Un seul champ incertain se transforme en foo?.bar, ce qui devient rapidement foo?.bar?.baz ; bientôt, tous les appels tolèrent silencieusement l’absence de données au lieu de se demander pourquoi un champ censé être obligatoire disparaît constamment. La syntaxe réduit les exceptions générées, mais elle peut tout aussi facilement augmenter l’ambiguïté globale.

Le fait de savoir si la chaînage optionnel est l’outil adapté doit découler du modèle de domaine, et non de la commodité. Si une valeur est véritablement optionnelle, le chaînage optionnel constitue un moyen élégant de l’exprimer. S’il s’agit d’une valeur obligatoire, permettre à undefined de se propager davantage dans le programme peut rendre beaucoup plus difficile l’identification de l’endroit exact où le bug a commencé.

3. La syntaxe de diffusion donne l’impression d’une copie complète, mais les objets imbriqués conservent leur identité originale

Diffuser un objet dans un nouveau permet d’écrire des mises à jour qui semblent immuables.

const updatedUser = {
  ...user,
  name: "Umar",
};

Au niveau le plus élevé, cette opération crée effectivement un nouvel objet avec les valeurs attendues. Ce succès pousse souvent les développeurs à interpréter { ...object } comme « cloner cet objet », alors que en réalité l’opération ne duplique que les propriétés situées directement sur cet objet.

Tout élément imbriqué d’un niveau supplémentaire ne bénéficie pas de ce traitement.

const original = {
  name: "Umar",
  preferences: {
    theme: "light",
  },
};

const copy = { ...original };

copy.preferences.theme = "dark";

console.log(original.preferences.theme);

// "dark"

Ici, copy est bien un objet externe distinct, mais copy.preferences fait toujours référence au même objet que original.preferences. L’opération de diffusion a dupliqué la référence, et non la structure qui y est associée.

Cela devient rapidement risqué dans la gestion de l’état, car le code résultant conserve l’apparence visuelle d’une mise à jour immuable.

const nextState = { ...state };
nextState.user.preferences.theme = "dark";

L’objet externe est nouveau, mais user et preferences peuvent très bien être les mêmes objets que dans l’état précédent. Tout ce qui décide s’il faut réafficher ou recalculer en se basant sur l’égalité de référence peut finir par examiner un objet hybride : certaines parties fraîchement créées, d’autres modifiées sur place. Entre-temps, tout autre morceau de code qui conserve encore une référence à l’ancien état verra également silencieusement cette modification.

Si seule une branche d’un objet imbriqué a réellement besoin de changer, copiez délibérément cette branche plutôt que de compter sur un seul spread au niveau supérieur.

const nextState = {
  ...state,
  user: {
    ...state.user,
    preferences: {
      ...state.user.preferences,
      theme: "dark",
    },
  },
};

Cela demande plus de frappe, mais chaque niveau qui change obtient une nouvelle identité, tandis que les parties que vous n’avez pas modifiées restent partagées en toute sécurité. Des outils comme Immer peuvent atténuer ce problème lorsque les arbres d’état deviennent profonds et imbriqués, mais ils ne suppriment pas l’exigence fondamentale : vous devez toujours savoir quelles références survivent à une mise à jour et lesquelles non.

La syntaxe de diffusion en elle-même n’est pas défectueuse. Le problème vient du fait de penser qu’une copie superficielle assure une isolation profonde.

Chaque fois que vous diffusez un objet pour le protéger de modifications ultérieures ailleurs, arrêtez-vous et vérifiez quelles références imbriquées restent partagées par la suite. Cette vérification est bien plus importante que le fait que la syntaxe utilise ou non ....

4. Le traitement via JSON.parse(JSON.stringify(...)) n’est pas identique à la clonage

Un truc efficace pour contourner les problèmes de copie superficielle est cette ligne unique :

const copy = JSON.parse(JSON.stringify(original));

Cela semble résoudre le problème des références imbriquées, car l’analyse de la chaîne JSON génère effectivement de nouveaux objets. Pour des données déjà au format JSON pur, cela peut même vous fournir exactement ce que vous espériez. Le problème réside dans le fait de considérer ce résultat comme une garantie générale de clonage profond. JSON ne comprend qu’un modèle de données limité. Dès que votre objet contient des valeurs en dehors de ce modèle, la sérialisation peut les modifier silencieusement, les supprimer ou provoquer une erreur. Prenons cet objet comme exemple :

const value = {
  createdAt: new Date(),
  missing: undefined,
  settings: new Map([["theme", "dark"]]),
};

Lorsqu’elle a été transmise via un aller-retour JSON, l’instance Date n’est plus qu’une chaîne de caractères et a perdu tout comportement spécifique à Date. La propriété undefined disparaît complètement. Le Map ne survit pas sous forme de Map avec ses paires clé-valeur intactes, ce qui pose un problème si l’appelant s’attendait à une copie structurelle correcte.

Les références circulaires perturbent encore davantage les choses :

const user = {
  name: "Umar",
};

user.self = user;

JSON.stringify(user);

// TypeError

D’autres types de valeurs entraînent également leurs propres problèmes. BigInt ne peut pas être converti via le mécanisme ordinaire JSON.stringify sans traitement supplémentaire, les propriétés identifiées par des symboles sont simplement ignorées, et les instances de classes personnalisées perdent le prototype qui leur conférait à l’origine un comportement spécifique à leur classe.

Rien de tout cela ne rend la sérialisation JSON peu fiable — elle fait exactement ce pour quoi elle a été conçue, à savoir compresser les valeurs JavaScript en une forme plus restreinte que JSON peut gérer.

Lorsque vous avez réellement besoin d’une copie structurée, structuredClone() est souvent un meilleur choix intégré, à condition que les types que vous utilisez soient pris en charge. Pour des données étroitement liées à votre domaine, écrire une fonction de copie explicite peut être encore plus fiable, car elle précise exactement quelles propriétés doivent être conservées au lieu d’assumer que toutes les propriétés doivent être clonées de manière générale.

Il convient également de noter que la clonage profond est souvent le mauvais outil. Si vous n’avez besoin de modifier qu’une branche imbriquée d’un objet, cloner toute la structure est une perte de temps et rend plus difficile de voir quelle partie a réellement changé.

Une bonne habitude est de donner à l’opération le nom qui lui convient. Lorsque vous souhaitez serialiser quelque chose, faites-le délibérément. Lorsque vous voulez cloner quelque chose, choisissez une méthode de clonage dont le comportement correspond réellement aux valeurs que vous stockez.

5. Promise.all() ne limite pas le nombre d’opérations exécutées en même temps

Imaginez une application qui doit rechercher des informations pour un groupe d’utilisateurs.

const users = await Promise.all(
  userIds.map((id) => fetchUser(id))
);

Pour un petit nombre d’utilisateurs, cela fonctionne bien et constitue généralement la bonne approche. Le code lance toutes les appels asynchrones, attend leur exécution collective, puis renvoie les résultats dans le même ordre que l’array d’entrée.

Plus tard, quelqu’un réutilise cette même fonction sur un ensemble de données comptant 50 000 enregistrements.

const users = await Promise.all(
  userIds.map((id) => fetchUser(id))
);

Rien n’a changé dans la syntaxe, mais l’échelle en a été affectée. Comme .map() appelle fetchUser pour chaque élément, et que chaque appel envoie probablement immédiatement une requête réseau ou de base de données, Promise.all() ne fixe aucune limite au nombre de ces requêtes pouvant s’exécuter simultanément.

Cela peut submerger un service intermédiaire, épuiser un pool de connexions, dépasser les limites de fréquence, augmenter fortement l’utilisation de la mémoire, ou déclencher une série de tentatives en boucle dès que des erreurs commencent à s’accumuler. Visuellement, le code reste lisible comme une opération par lots ordonnée, ce qui rend facile de négliger le coût réel de son exécution.

Il existe une deuxième hypothèse implicite dans Promise.all() : dès qu’une promesse échoue, tout le groupe échoue également. C’est le comportement souhaité lorsque l’on traite ce groupe comme une seule unité de travail, mais cela ne signifie pas pour autant que les autres opérations en cours sont annulées — elles continuent de s’exécuter même après l’apparition d’une erreur.

Lorsque les tâches sont en réalité indépendantes et que l’on souhaite obtenir tous les résultats quelles que soient les échecs individuels, Promise.allSettled() est souvent plus adapté pour exprimer ce que l’on cherche à réaliser.

const results = await Promise.allSettled(
  userIds.map((id) => fetchUser(id))
);

Mais si votre véritable préoccupation concerne la charge sur un autre système, alterner entre Promise.all() et Promise.allSettled() ne résoudra pas ce problème à lui seul — aucun de ces deux outils ne limite la concurrence. Ce dont vous avez réellement besoin, c’est du regroupement en lots, d’un sématique, d’une file d’attente, ou d’un outil de limitation de concurrence qui ne maintient qu’un nombre fixe d’opérations actives en même temps.

Cette approche est tout à fait acceptable lorsque l’ensemble des données d’entrée reste petit et prévisible, et que le système cible peut absorber la charge parallèle. Le problème apparaît plus tard, lorsque de la logique conçue pour gérer dix enregistrements est appliquée à dix mille, sans que personne ne s’arrête pour réévaluer si cela reste sécurisé.

La concurrence ne dépend pas de la manière dont vous attendez vos promesses — elle dépend du volume de travail que vous lancez et de ce que les systèmes sous-jacents peuvent réellement gérer.

6. Les méthodes d’array à apparence fonctionnelle peuvent encore muter

Les méthodes d’array en JavaScript permettent de créer des pipelines de données compacts et lisibles, ce qui est généralement un avantage réel.

const activeUsers = users
  .filter((user) => user.active)
  .map((user) => user.name);

Puisque filter() et map() renvoient tous deux de nouveaux arrays, il est tentant de penser que toute chaîne de méthodes d’array laisse l’original inchangé. Cette supposition s’effondre dès que sort(), reverse(), splice() ou quelques autres méthodes mutantes rejoignent la chaîne.

Imaginez un composant React qui reçoit un array users en tant que prop :

function UserList({ users }) {
  const sortedUsers = users.sort(
    (a, b) => a.name.localeCompare(b.name)
  );

  return sortedUsers.map((user) => (
    <User key={user.id} user={user} />
  ));
}

Il semble que ce code crée un nouveau tableau sortedUsers, mais en réalité sort() réorganise le tableau d’origine sur place et ne renvoie qu’une référence à ce même tableau. Ainsi, un composant dont la seule fonction est de rendre de l’interface modifie, par effet secondaire, des données appartenant à son composant parent.

Ce type de mutation peut rester caché pendant longtemps, jusqu’à ce qu’une autre partie de l’application ait besoin de l’ordre d’origine. Les tests de ce composant continuent de passer, car le résultat affiché semble correct, tandis qu’un composant frère ou une valeur mémorisée ailleurs commence silencieusement à utiliser des données réorganisées auxquelles il ne s’attendait pas.

Une solution consiste à cloner le tableau avant d’appliquer une méthode mutante.

const sortedUsers = [...users].sort(
  (a, b) => a.name.localeCompare(b.name)
);

Les environnements JavaScript plus récents proposent également des équivalents non mutants, tels que toSorted(), si l’environnement cible les prend en charge.

La véritable leçon ici n’est pas de mémoriser quels méthodes d’array sont « sûres ». Il s’agit plutôt de réfléchir à savoir si une opération respecte celui qui possède les données.

Si l’on donne à une fonction une valeur qu’elle ne possède pas et que l’on la modifie en silence, cela entraîne un comportement qui n’a rien à voir avec ce pour quoi la fonction est censée être responsable. Le code devient bien plus facile à comprendre lorsque la modification ne se produit pas du tout, ou qu’elle a lieu de manière explicite, au niveau de celui qui possède réellement la valeur.

7. Les tables de recherche peuvent masquer des entrées invalides au lieu de les rejeter

Il est courant de remplacer une instruction switch par un objet simple utilisé comme table de dispatch, car cela se lit comme une déclaration plus directe.

const handlers = {
  create: createUser,
  update: updateUser,
  delete: deleteUser,
};

handlers[action]?.(payload);

Cela est compact, et le dispatch par recherche constitue effectivement un bon pattern dans de nombreux cas. Ce qui est facile à négliger, c’est ce qui se passe lorsque action ne correspond à aucune clé connue.

Puisque cet exemple fait appel au chaînage optionnel, une action non reconnue ne provoque aucun effet, en silence.

action = "udpate"; // typo

handlers[action]?.(payload); // no handler runs

Un simple erreur de frappe peut désormais circuler dans le système sous couvert d’une opération sans effet réussie. À moins que l’appelant ne vérifie la valeur de retour significative, le reste de l’application peut indiquer que l’opération a réussi alors qu’en réalité rien n’a été exécuté.

Faire échouer l’opération de manière visible rend le contrat bien plus transparent.

const handler = handlers[action];

if (!handler) {
  throw new Error(`Unsupported action: ${action}`);
}

return handler(payload);

Les tables de recherche deviennent plus risquées lorsque leurs clés proviennent des entrées utilisateur ou de sources peu validées. Les objets simples héritent également de propriétés de leur prototype à moins de prendre des mesures pour l’empêcher, donc traiter des chaînes arbitraires comme des recherches de propriétés sûres exige plus de prudence que pour un petit ensemble fermé de commandes connues en interne.

L’utilisation d’un Map peut rendre l’intention de type dictionnaire plus claire :

const handlers = new Map([
  ["create", createUser],
  ["update", updateUser],
  ["delete", deleteUser],
]);

const handler = handlers.get(action);

if (!handler) {
  throw new Error(`Unsupported action: ${action}`);
}

return handler(payload);

Au TypeScript, associer une union discriminée à un switch exhaustif peut être encore plus avantageux, car l’introduction d’une nouvelle action oblige alors le compilateur à signaler tous les endroits du codebase qui doivent être mis à jour.

Rien de tout cela ne signifie pour autant que le switch soit catégoriquement supérieur à une table de recherche. Les tables de dispatch se révèlent utiles lorsque les opérations sont naturellement basées sur des données et que le comportement pour des clés inconnues a été délibérément défini.

Les problèmes commencent lorsque l’indirection rend impossible de distinguer le cas où "le programme a traité délibérément cette situation" de celui où "rien ne s’est passé du tout">.

Le véritable coût de l’ingéniosité, ce sont des hypothèses cachées

Aucune de ces sept habitudes ne mérite une interdiction absolue.

Les vérifications de vérité fonctionnent bien lorsque la notion de vérité correspond réellement aux règles de votre domaine. La chaînage optionnel est idéal pour les relations qui sont véritablement facultatives. La propagation d’objets convient parfaitement aux mises à jour superficielles. JSON est le format naturel pour les données de forme JSON. Promise.all() est parfaitement adapté aux tâches parallèles limitées. Les méthodes de modification d’array sont acceptables lorsque vous en possédez pleinement le contrôle. De plus, les tables de recherche peuvent rendre la logique de traitement extrêmement simple.

Le risque réside dans le fait de considérer leurs valeurs par défaut pratiques comme des garanties que la syntaxe ne pouvait jamais vraiment fournir. !value ne signifie pas « manquant ». ?. ne signifie pas « présent et valide de manière sûre ». { ...obj } ne signifie pas « copie entièrement indépendante ». La sérialisation en JSON puis sa déserialisation ne signifie pas « clone générique ». Et Promise.all() ne signifie pas « concurrence contrôlée ».

C’est pourquoi la version la plus facile à maintenir d’un morceau de JavaScript n’est pas toujours la plus courte. Quelques lignes supplémentaires permettant de distinguer undefined de 0, de rejeter une structure d’objet mal formatée, de limiter le nombre de requêtes parallèles ou de lancer une erreur en cas d’action non reconnue peuvent préserver des informations que les futurs mainteneurs devraient sinon chercher dans l’historique des commits.

Lorsque le code vous semble exceptionnellement ingénieux, prenez un instant pour examiner ce qui a été compressé. Parfois, la réponse est que seules des formalités inutiles ont été supprimées tandis que le sens restait intact — ce qui est précisément l’objectif d’une bonne abstraction. D’autres fois, ce qui a été compressé est une distinction dont les exigences actuelles n’avaient tout simplement pas encore besoin.

C’est ce deuxième cas qui a tendance à engendrer les bugs de demain.

Une habitude utile lors de l’examen du code est de se demander quelle condition doit rester vraie pour que un schéma concis continue de fonctionner lorsque les exigences évoluent. Si cette condition est importante mais n’apparaît nulle part dans le code, notez-la explicitement tant que la logique est encore fraîche et facile à suivre.

Les futurs mainteneurs rencontrent rarement des problèmes parce que JavaScript leur permet d’écrire trop de code. Ils ont des difficultés car le code cesse progressivement de leur indiquer quels sont les critères vraiment importants.

Lectures complémentaires

  • Sept signaux d’alerte des revueurs qui prédisent des changements coûteux à venir — Apprenez à repérer les sept problèmes courants dans le code que les revueurs signalent tôt, allant des flags de mode booléen aux erreurs ignorées, ainsi que la manière d’évaluer quand chacun d’eux constitue un véritable problème.