← Retour au blog
JavaScript Une ligne import { something } from "./foo" entourée de points d'interrogation : la même instruction peut résoudre vers foo.ts, foo.tsx, foo/index.ts ou foo/index.js, illustrant l'ambiguïté de la résolution de modules.

Le système de modules JavaScript : un enfer pour les outils

Pourquoi un simple import déclenche des dizaines d'accès disque, et comment les extensions explicites (allowImportingTsExtensions) simplifient la résolution de modules.

📅 ✍️ Antoine Coulon
modulesmodule-resolutioncommonjsesmtypescripttooling

Une seule ligne de code, en apparence anodine, peut déclencher plus d’une dizaine d’opérations coûteuses sur le disque. Cette ligne, vous l’écrivez des dizaines de fois par jour :

import { something } from "./foo";

Le système de modules JavaScript est un véritable enfer, pour les humains comme pour les outils. Et tout part d’un constat simple : cet import est fondamentalement ambigu. Le moteur (runtime, bundler, compilateur ou analyseur statique) ne sait pas vers quel fichier "./foo" pointe réellement. Il doit le deviner.

Pourquoi un import est ambigu

L’ambiguïté ne vient pas d’un défaut isolé, mais de l’accumulation de plusieurs couches d’histoire et de standards qui coexistent dans l’écosystème :

Cette complexité fonctionnelle se traduit par deux coûts bien réels. D’abord une complexité cognitive : le développeur doit garder en tête les règles de résolution pour comprendre ce qui sera réellement importé. Ensuite une complexité incompressible pour le tooling : les module bundlers, les linters, les compilateurs et tous les outils d’analyse statique (skott, knip, NodeSecure, etc.) doivent reproduire fidèlement ces règles pour faire leur travail correctement.

La résolution de modules, ou l’art de chercher un fichier à tâtons

Ne pas spécifier d’extension, c’est demander à l’outil de partir à la recherche du fichier sur le disque. C’est précisément ce qu’on appelle la résolution de modules. Et cette recherche doit explorer toutes les variantes possibles, dans un ordre de priorité déterminé :

Et ce n’est que la première moitié du problème. Il faut encore gérer la résolution des fichiers d’index : "./foo" peut tout aussi bien désigner un dossier foo/ contenant un foo/index.js, et là encore, pour chacune des variantes d’extension citées plus haut.

Concrètement, pour un seul import, l’outil peut avoir à tester foo.ts, foo.tsx, foo/index.ts, foo/index.js et bien d’autres candidats. Chacune de ces tentatives est un appel système au disque, donc une opération coûteuse.

Au mieux, le fichier est trouvé dès la première tentative. Au pire, il faut une dizaine de vérifications infructueuses avant de tomber sur le bon. Multipliez cela par le nombre d’imports d’un projet réel, et l’addition devient salée.

Les outils ne sont évidemment pas naïfs : avec de l’information contextuelle et un jeu d’heuristiques (cache du système de fichiers, priorisation des extensions selon la configuration, mémorisation des résolutions déjà effectuées), ils parviennent à optimiser ce parcours. Mais ils optimisent un problème qui, à la racine, ne devrait pas exister.

Et si on arrêtait de deviner ?

La meilleure façon de résoudre une ambiguïté, c’est de la supprimer à la source. Plutôt que de laisser l’outil deviner l’extension, autant la déclarer explicitement.

Ce n’est pas une idée nouvelle : c’est déjà le fonctionnement des ECMAScript modules, qui imposent des extensions de fichiers explicites dans les imports. Avec ESM, import { x } from "./foo.js" ne laisse aucune place au doute, et la résolution devient triviale.

Côté TypeScript, l’écriture explicite a longtemps été bloquée par le compilateur. Depuis la version 5, une nouvelle option de configuration vient lever ce verrou : allowImportingTsExtensions. Elle autorise les imports à mentionner l’extension exacte du fichier source, y compris les extensions TypeScript :

import { something } from "./foo.tsx";

Dans tsconfig.json, cela se résume à activer l’option :

{
  "compilerOptions": {
    "allowImportingTsExtensions": true
  }
}

Les avantages des extensions explicites

Déclarer l’extension dans l’import présente des bénéfices immédiats, à la fois pour la machine et pour le lecteur :

La contrepartie : un bundler devient nécessaire

L’approche n’est pas magique pour autant, et il faut en connaître les limites :

La conséquence est qu’il faut passer par un module bundler (ou un outil équivalent capable de réécrire ces chemins). Loin d’être une contrainte gênante, c’est en réalité un échange gagnant : le bundler sait gérer ces extensions, et comme le travail de résolution lui est en grande partie pré-mâché, il s’exécute lui aussi plus rapidement.

Conclusion

Le système de modules JavaScript est complexe parce qu’il porte l’héritage de plusieurs standards qui se superposent. Cette complexité a un coût concret, mesurable en appels disque, et c’est tout l’écosystème d’outillage qui le paie à chaque import non résolu.

La direction prise par l’écosystème est claire et de bon sens : remplacer la devinette par l’explicite. Les ECMAScript modules l’imposent déjà, et allowImportingTsExtensions étend cette philosophie au monde TypeScript. Écrire l’extension exacte de ses imports, c’est gagner en lisibilité pour soi et en performance pour ses outils, à condition d’assumer la présence d’un bundler dans la chaîne. Un compromis qui, au vu des bénéfices, vaut largement la peine d’être fait.