Accueil / Articles

Dix patterns courants en JavaScript et les compromis associés à chacun d’eux

Clauses de protection, tableaux de recherche, objets de résultat, AbortController, allSettled, usines et petits caches : quand chaque pattern est utile et quand il se révèle contreproductif.

Éditeur : REACTAPP.TOP13 min de lecture2912 mots

Sources: I Reuse These 10 JavaScript Patterns in Almost Every Project

Que ce soit un tableau de bord React, une API Node.js ou un petit script d’automatisation, tout codebase finit par faire face aux mêmes défis : les fonctions reçoivent des données incomplètes, les réponses asynchrones arrivent dans le mauvais ordre, les conditions s’encombrent à cause d’évolutions des règles métier, plusieurs versions de la même transformation divergent, et le traitement des erreurs brouille la distinction entre « ceci a échoué » et « il n’y avait tout simplement rien à retourner ». Les frameworks viennent et partent, mais ces problèmes restent remarquablement stables. Les dix modèles présentés ci-dessous constituent des solutions volontairement simples à ces problèmes. Chacun réduit l’ensemble d’états dont une fonction doit tenir compte ou rend la responsabilité plus claire ; pour chacun d’eux, vous apprendrez également où il cesse d’être utile, car aucun ne remplace le jugement humain.

1. Les clauses de protection maintiennent la lisibilité du chemin principal

Lorsque le travail réel d’une fonction se trouve à l’intérieur de plusieurs blocs if imbriqués, le lecteur doit garder en tête une série de questions : l’entrée est-elle présente ? L’appelant est-il autorisé ? Le record a-t-il été trouvé ? Se trouve-t-il dans un état permettant ce changement ? Chaque condition supplémentaire ajoute un niveau d’indentation ainsi qu’un élément de plus à retenir.

async function publishArticle(articleId, currentUser) {
  if (!articleId) {
    throw new Error("Article ID is required");
  }

  if (!currentUser?.permissions.includes("article:publish")) {
    throw new Error("You are not allowed to publish articles");
  }
  const article = await articleRepository.findById(articleId);
  if (!article) {
    throw new Error("Article not found");
  }
  if (article.status !== "draft") {
    throw new Error(`Cannot publish an article in ${article.status} status`);
  }
  return articleRepository.update(articleId, {
    status: "published",
  });
}

Le véritable avantage n’est pas un moindre nombre d’indentations, mais une certitude croissante. Après le premier contrôle, l’ID existe bien. Après le deuxième, l’appelant dispose des autorisations nécessaires. Après le troisième, article est un objet réel, ce qui permet d’éviter que la chaînage optionnel ne s’étende au reste du code. L’appel final à update peut être lu sans qu’aucun élément reste en suspens. Un petit cas limite à noter : currentUser?.permissions.includes(...) génère toujours une erreur TypeError si l’objet utilisateur ne contient pas de tableau permissions, ce qui correspond précisément au type d’incohérence que le prochain schéma vise à résoudre.

Les vérifications deviennent un problème lorsque une fonction longue contient de nombreuses sorties pour chaque condition mineure. Elles sont les plus utiles pour les préconditions, les états invalides, le manque de ressources et les résultats déjà finalisés. Si ces vérifications commencent à décrire plusieurs règles métier distinctes, la fonction prend probablement trop de décisions et devrait être divisée.

2. Normaliser les données une seule fois, à la frontière

JavaScript permet aux valeurs ayant le même sens de se présenter sous de nombreuses formes. Un indicateur peut être true, "true", 1 ou "1". Un ID peut être un nombre dans la base de données et une chaîne de caractères dans l’URL. Une date peut être un objet Date, une chaîne ISO, une chaîne locale ou une horodatage. Si chaque fonction interprète ces éléments de manière indépendante, l’application accumule progressivement plusieurs définitions concurrentes de ce qu’est une donnée valide.

La solution consiste à convertir les valeurs une seule fois, au moment où elles franchissent une limite significative, avant que le reste du code ne les traite :

  • Un gestionnaire d’API transforme les chaînes au niveau du transport en les types attendus par la couche de service.
  • Un adaptateur de données mappe une réponse externe sur la structure interne du projet.
  • Un gestionnaire de soumission de formulaire supprime les espaces inutiles dans les chaînes et traite les champs optionnels avant l’exécution de la logique métier.

Le timing est crucial. Lorsque la conversion est reportée, des vérifications de sécurité se répandent partout : une fonction teste status === true, une autre teste status === "true", et une troisième se base sur la propriété de vérité. Chacune fonctionne pour son appelant actuel, mais la signification de status n’est plus stable.

Un contrat interne restreint rend également les tests plus ciblés. Lorsque la couche de requêtes fournit déjà une valeur booléenne authentique, le service n’a pas besoin de tests pour chacune de ses variantes textuelles. La couche de normalisation a toujours besoin de ses propres tests, mais tout ce qui suit devient plus simple.

La normalisation n’est pas synonyme d’acceptation de tout. Elle est appropriée lorsque plusieurs représentations sont intentionnellement prises en charge ; les valeurs ambiguës ou invalides doivent néanmoins être rejetées. Transformer "abc" en NaN et le laisser passer n’est pas de la normalisation, c’est une validation reportée à un moment ultérieur et plus confus.

3. Les objets Options rendent les appels auto-descriptifs et plus faciles à étendre

Les paramètres positionnels conviennent bien pour une ou deux entrées évidentes. Ils deviennent fragiles à mesure que la fonction s’agrandit, surtout lorsque plusieurs paramètres partagent le même type. Une appelation telle que createUser(name, email, true, false, 3) oblige le lecteur à se référer à la définition pour comprendre ce que signifie chaque paramètre, et les types de TypeScript indiqués dans la déclaration ne sont d’aucune aide lors de l’appel.

Lorsqu’une fonction commence à accepter plusieurs valeurs de type configuration, un seul objet options est généralement plus clair :

await createUser({
  name,
  email,
  sendWelcomeEmail: true,
  requireVerification: false,
  retryLimit: 3,
});

L’appel s’auto-documente désormais, les propriétés optionnelles peuvent avoir des valeurs par défaut (généralement via le déstructuration dans la signature), et l’ajout d’une nouvelle option n’oblige pas tous les appelsants à réorganiser leurs arguments. Les fonctions de service, les outils de configuration, les chargeurs de données et les utilitaires en évolution en bénéficient le plus.

Cela élimine également l’aspect typique lié aux paramètres booléens. Une simple valeur true dans une appel indique presque jamais quel comportement elle active ; en revanche, une propriété nommée le fait.

N’appliquez pas cette méthode partout. add({ a, b }) n’est pas plus clair que add(a, b). Recourrez à un objet d’options lorsque les arguments décrivent des paramètres de configuration, lorsqu’il y a plusieurs arguments optionnels, lorsque plusieurs valeurs partagent le même type, ou lorsque la signature est susceptible de changer fréquemment.

4. Les tableaux de recherche transforment les branches en relations explicites

Des chaînes d’instructions if ou de longs blocs switch apparaissent souvent lorsque une valeur choisit un gestionnaire, un formatteur, un validateur ou une stratégie. Chaque nouveau cas allonge le flux de contrôle et ajoute un point où il faut prendre en compte le comportement par défaut.

const statusHandlers = {
  pending: handlePendingOrder,
  paid: handlePaidOrder,
  cancelled: handleCancelledOrder,
};

function processOrder(order) {
  const handler = statusHandlers[order.status];
  if (!handler) {
    throw new Error(`Unsupported order status: ${order.status}`);
  }
  return handler(order);
}

Il existe un piège subtil avec les objets simples. Les propriétés héritées de Object.prototype, telles que "toString" ou "constructor", sont également des fonctions, ce qui peut faire en sorte qu’une valeur de statut inattendue sélectionne l’une d’elles au lieu de déclencher la vérification prévue. Si les propriétés proviennent des entrées de l’utilisateur, utilisez un Map, un objet créé avec Object.create(null), ou une vérification via Object.hasOwn.

Évitez les tableaux de recherche lorsque la logique est véritablement séquentielle. Si une décision dépend de combinaisons d’état, de choix antérieurs ou de transitions ordonnées, la transformation en tableau masque généralement le flux au lieu de le clarifier. Le tableau est utile lorsque une seule clé correspond à un comportement complet et facilement compréhensible de manière indépendante.

Évitez d’inclure une logique complexe directement dans la carte elle-même. Les fonctions de gestion nommées génèrent des traces d’exception plus claires, sont plus faciles à tester de manière isolée, et permettent à la carte de se concentrer sur la sélection.

5. Les objets de résultat séparent les échecs attendus des exécutions défaillantes

Les exceptions sont l’outil adapté aux situations inattendues, mais de nombreux échecs sont tout à fait normaux. Une recherche peut ne rien trouver. Une inscription peut être rejetée car l’adresse e-mail est déjà utilisée. Une transition d’état peut être interdite par les règles en vigueur. Si tous ces cas sont traités comme des exceptions génériques, ceux qui appellent la fonction doivent analyser des messages d’erreur ou gérer des types d’erreurs larges simplement pour comprendre ce qui s’est passé.

Lorsqu’une opération peut avoir plusieurs résultats attendus, le retour d’un petit objet de résultat en fait partie intégrante du contrat. La fonction de remise ci-dessous renvoie les valeurs « non trouvé » et « expiré » en tant que données, chacune accompagnée d’un code lisible par la machine, et restitue la commande ajustée en cas de succès.

function applyDiscount(order, discount) {
  if (!discount) {
    return {
      ok: false,
      code: "DISCOUNT_NOT_FOUND",
      message: "The requested discount does not exist.",
    };
  }

  if (discount.expiresAt < new Date()) {
    return {
      ok: false,
      code: "DISCOUNT_EXPIRED",
      message: "The discount has expired.",
    };
  }
  return {
    ok: true,
    value: {
      ...order,
      total: order.total - discount.amount,
    },
  };
}

Les appels peuvent désormais prendre des décisions en fonction de ok et code sans avoir à lire du texte destiné aux utilisateurs. Le nom exact des champs importe moins que l’utilisation cohérente d’une même structure : certains projets modélisent cela sous forme d’union discriminée en TypeScript avec ok, tandis que d’autres utilisent success, data et error. Ce qui compte, c’est que le résultat soit explicite. Dans une implémentation réelle, il faudrait également déterminer ce qui se passe lorsque la remise dépasse le montant total de la commande, car la soustraction présentée ici peut produire un montant négatif.

Les objets de résultat ne remplacent pas les exceptions partout. Lancez une exception lorsque une dépendance échoue de manière inattendue, lorsqu’une invariante est violée, ou lorsque la couche actuelle ne peut rien faire de utile pour résoudre le problème. Retournez les résultats lorsque l’échec fait partie intégrante du domaine d’application et que l’appelant doit y réagir.

Un avantage supplémentaire est la facilité de test : les cas d’échec sont spécifiés dans le type de retour, ce qui permet de les vérifier facilement. De plus, un bloc try/catch large n’associe plus une règle métier rejetée à une connexion de base de données interrompue.

6. AbortController rend explicite la gestion asynchrone

De nombreux bugs côté frontend proviennent de tâches asynchrones qui se chevauchent. Un utilisateur modifie un terme de recherche, une deuxième requête est lancée, et la première se résout en dernier. Chaque requête a réussi, mais l’écran affiche des résultats obsolètes.

La demande n’est pas le problème ; la propriété l’est. L’application doit décider quel résultat en cours est encore autorisé à mettre à jour l’état. AbortController exprime cette décision :

  • Dans une zone de recherche, annuler la demande précédente chaque fois que la requête change.
  • Dans un composant, annuler le travail en attente lorsqu’il est désinstallé.
  • Dans un flux de travail composé d’étapes dépendantes, transmettre un signal partagé à travers toutes pour que une seule annulation arrête toute la chaîne.

Gérer délibérément les annulations. Une interruption signifie généralement que l’application a jugé que le résultat n’était plus pertinent, ce qui diffère d’une panne. Avec fetch, une requête interrompue est rejetée avec une erreur dont le name est "AbortError", ce qui vous permet de la reconnaître explicitement. En enregistrant chaque interruption comme une erreur, vous inondez votre système de surveillance de données superflues, tandis que traiter les véritables pannes réseau comme des interruptions fait disparaître des éléments d’information utiles.

Plus une application dépend de la récupération en arrière-plan, de la recherche en temps réel, des transitions de route et de requêtes en chaîne, plus ce point devient important. Lancer des tâches asynchrones en JavaScript est aisé ; AbortController définit le moment où cette tâche cesse d’appartenir à son initiateur. Pour en savoir plus sur le cas de la recherche, consultez comment résoudre les conditions de course que le débouncing ne peut pas résoudre.

7. Promise.allSettled permet de conserver les résultats partiels des tâches indépendantes

Promise.all convient lorsque le résultat combiné n’a de sens que si toutes les opérations réussissent. Il est trop intrusif lorsque les tâches sont indépendantes : un échec entraîne l’abandon de toutes les valeurs qui ont bien été reçues.

Imaginez un tableau de bord composé de chiffres de ventes, de notifications, de nouvelles inscriptions et d’informations sur la santé du système, chacun provenant d’une source différente. Si un service optionnel tombe en panne, afficher un tableau de bord vide est souvent une expérience pire que de présenter ce qui a bien chargé et d’indiquer ce qui ne l’a pas fait.

Promise.allSettled attend la fin de chaque promesse et rapporte chacun des résultats individuellement, sous forme d’un objet contenant soit status: "fulfilled" accompagné d’une value, soit status: "rejected" accompagné d’une reason. L’utilisateur peut alors utiliser les valeurs réussies, lister les échecs et décider si l’opération globale reste pertinente.

Ne cherchez pas à le faire simplement pour que l’ensemble paraisse fiable. Certaines séries de promesses représentent, en termes commerciaux, une seule transaction. Si la création d’une commande, la réserve de stock et le prélèvement sur carte doivent réussir ou échouer ensemble, obtenir des résultats partiellement traités entraîne un état incohérent ; vous avez alors besoin d’actions compensatoires ou d’une stratégie d’orchestration plus solide.

Utilisez allSettled lorsque les tâches sont réellement indépendantes et que l’appelant dispose d’un plan défini en cas d’échec partiel. La vraie question n’est pas de savoir quel outil Promise est le plus pratique, mais plutôt s’il est encore possible d’obtenir une réponse honnête lorsque l’une des étapes a échoué. D’autres options sont abordées dans neuf modèles Promise pour un JavaScript asynchrone fiable en production.

8. Les fonctions de transformation pure facilitent le test des règles de données

Une grande partie du code d’application mélange le traitement des données avec leur récupération, l’enregistrement, l’affichage, la persistance et les mises à jour d’état. Tester une règle simple exige alors de reconstruire tout l’environnement autour d’elle.

L’intégration de transformations stables dans de petites fonctions pures résout ce problème. Une fonction pure prend une entrée, renvoie une sortie et ne modifie rien en dehors d’elle-même. Les exemples typiques incluent la normalisation d’un enregistrement API, le calcul de valeurs dérivées, le regroupement d’éléments, l’assemblage d’objets de requête ou la transformation des données métier en modèle de vue.

L’avantage majeur réside dans la création d’une frontière claire entre la récupération des données et leur interprétation. Une fonction qui regroupe les enregistrements d’employés n’a pas besoin de savoir comment ils ont été chargés. Une fonction qui construit un en-tête de requête n’a pas besoin de l’état des composants si les valeurs peuvent être transmises en tant qu’arguments.

Le débogage devient également plus simple. Lorsque des entrées correctes produisent des sorties incorrectes, vous pouvez appliquer la transformation directement sur ces dernières. Si la transformation est correcte, l’enquête se porte alors sur ce qui a généré l’entrée ou consommé la sortie.

Toute expression ne mérite pas sa propre fonction ; une série d’encadrements en une seule ligne rend la navigation plus difficile sans ajouter de valeur réelle. Extraire des fonctions uniquement lorsque la transformation encode une règle métier réutilisable, présente plusieurs branches de traitement, ou nécessite des tests spécifiques.

9. Les usines révèlent les dépendances et la configuration partagée

Une usine constitue l’un des moyens les plus simples de contrôler les dépendances d’un module. Au lieu d’importer un client de base de données, un générateur d’entrées, un cache ou des paramètres de configuration depuis une source globale, l’usine les reçoit en arguments et renvoie des fonctions qui s’appuient sur eux.

Cela est particulièrement utile dans les services backend et les modules partagés. Les dépendances deviennent visibles, le couplage caché diminue, et les tests peuvent utiliser un répertoire ou un journaliseur fictif sans avoir à mocker les importations de modules, tandis que la version en production utilise ceux réels. La logique métier reste axée sur le comportement, et non sur la création de ses composants.

Les usines conviennent également aux groupes de fonctions qui partagent une configuration stable. Une usine de client HTTP peut capturer une URL de base, un jeton d’authentification, un délai d’attente et un journaliseur une seule fois, puis exposer des méthodes qui les utilisent toutes, grâce à des closures qui conservent cette configuration afin qu’elle ne soit pas transmise à chaque appel.

Le risque réside dans la création d’un framework d’injection de dépendances développé en interne lorsque le code existant dispose déjà d’un tel framework, ou que une simple importation suffirait. Utilisez des factories lorsque les dépendances modifient de manière significative le comportement, lorsque les tests nécessitent un contrôle sur elles, ou lorsqu’il faut que plusieurs instances configurées différemment coexistent. L’objectif est de révéler les dépendances, et non de créer un autre framework à entretenir.

10. Les caches Map et WeakMap évitent les tâches redondantes

Map et WeakMap constituent de bons caches locaux lorsque le même calcul coûteux ou des métadonnées spécifiques à un objet devraient sinon être recalculés. Un analyseur peut mémoriser les résultats pour chaque chaîne d’entrée. Un formatteur peut réutiliser des données dérivées coûteuses. Un WeakMap, dont les clés doivent être des objets, peut associer des métadonnées à ces objets sans les conserver en mémoire une fois qu’aucun autre élément ne fait référence à eux.

Rédiger dans le cache est la partie facile. Déterminer si une valeur en cache reste valide est la partie difficile.

Les caches présentent des problèmes de manière prévisible :

  • La clé omet un paramètre qui influence le résultat, de sorte qu’un cache qui semble fonctionner correctement lors des tests fournit des valeurs obsolètes en production.
  • Les données de base changent sans que rien ne vaille à invalider l’entrée correspondante.
  • L’expansion est illimitée, comme c’est le cas avec un Map au niveau du module qui reste actif tout au long de la durée de vie d’un processus serveur.

Au préalable, avant d’ajouter un cache, identifiez un coût réel et récurrent ainsi que son responsable, la structure de sa clé, sa durée de vie et les règles d’invalidation. Souvent, recalculer est plus sûr et suffisamment rapide. Le cache trouve son utilité lorsque le travail est coûteux, que les entrées sont stables et que son champ d’application peut rester limité.

Cet exemple illustre bien toute la liste. Map rend l’écriture de mécanismes de mise en cache très simple, mais la facilité n’est pas synonyme de correction : l’amélioration des performances n’est réelle que si l’on peut expliquer quand une entrée cesse d’être vraie.

Les patterns doivent éliminer les décisions, pas les cacher

Tous ensemble, ces schémas s’imposent en éliminant l’ambiguïté plutôt qu’en mettant en avant des caractéristiques linguistiques. Les gardiens éliminent rapidement les chemins invalides ; la normalisation en amont permet au code interne d’obtenir des valeurs prévisibles ; les objets options rendent les appels lisibles et ouverts à l’extension ; les tableaux de recherche révèlent des relations directes ; les objets de résultat maintiennent la signification des sorties ; AbortController attribue la responsabilité aux tâches asynchrones ; allSettled indique honnêtement les échecs partiels ; les fonctions pures isolent les décisions des effets secondaires ; les usines mettent en évidence les dépendances ; et de petits caches évitent le travail répétitif lorsque leur validité est claire.

Chacun d’eux peut également être mal appliqué. Envelopper une appel à deux arguments dans un objet options n’est qu’une formalité ; renvoyer des résultats depuis du code qui devrait lancer une exception en complique la compréhension ; un map peut masquer une logique ordonnée ; un cache peut devenir obsolète ; et une usine à objets peut représenter une abstraction inutile. Un motif devient nocif lorsqu’on le choisit en raison de son apparence soignée plutôt que pour la pression réelle qu’il soulage.

Points clés

  • Jugez un motif en fonction du compromis qu’il implique, et non de son apparence sophistiquée.
  • Préférez des structures dont les coûts sont visibles et dont les avantages perdurent malgré les changements ultérieurs.
  • De bons motifs rendent explicites les hypothèses, les responsabilités, les modes de défaillance et les chemins valides d’une fonction.
  • Les meilleurs disparaissent dans l’ombre : les lecteurs remarquent que le problème devient plus simple, et non le motif lui-même.

Lectures complémentaires