Modules — ESM vs CJS, dual packaging, TS interop
TL;DR — ESM est la norme JavaScript depuis 2015 ; CJS est l'héritage Node. En 2026 sur Node 22/24, écris ESM, publie en dual package quand tu vises un public mixte, utilise le champ
exportsavec des conditional exports pour gérer les variantes (import/require/types/browser), et profite derequire(esm)activé par défaut dès Node 22.12 LTS (et 24) pour ne plus craindre les libs ESM-only. Pour TypeScript, soit tu compiles vers ESM avectsc/tsup, soit tu exécutes directement.tsviatsx(loader hook) ou le strip-types natif de Node 22+ (--experimental-strip-types, stable en 24).
🧠 Mental model — ASCII + analogie
┌──────────────────────────────────────────────────────────┐
│ Comment Node décide si un fichier est ESM ou CJS ? │
└──────────────────────────────────────────────────────────┘
.mjs ─────────────────────────────► ESM
.cjs ─────────────────────────────► CJS
.js ───┬─ package.json "type":"module" ─► ESM
└─ sinon ─► CJS
.ts ───┬─ tsconfig moduleResolution + --strip-types
│ selon "type" du package.json
└─ via tsx/ts-node loader
┌──────────────────────────────────────────────────────────┐
│ Résolution d'un import "lib/sub" │
└──────────────────────────────────────────────────────────┘
node_modules/lib/package.json
{
"exports": {
".": { "import": "./esm/index.js",
"require": "./cjs/index.cjs",
"types": "./types/index.d.ts" },
"./sub": { "import": "./esm/sub.js",
"require": "./cjs/sub.cjs" }
}
}
▲
│ Node consulte exports en premier
│ "main" est ignoré si exports existe
│ Pas listé dans exports → ENOTFOUNDAnalogie : un module est une boîte. CJS est une boîte en bois clouée — pour l'ouvrir tu appelles require(), qui exécute tout le contenu synchronement et te rend module.exports. ESM est une boîte en métal avec des compartiments transparents (les exports) ; Node analyse la structure avant d'exécuter, ce qui permet du tree-shaking et du top-level await. Tu ne peux pas mélanger les deux n'importe comment : un require() d'une boîte métal est interdit (sauf désormais avec require(esm) sur Node 22+, mais seulement si la boîte n'utilise pas top-level await).
🛠️ Code minimal (ts/js)
Module ESM avec top-level await
// src/db.ts (ESM, dans un package "type":"module")
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
// Top-level await — disponible uniquement en ESM. Ici on valide la connexion
// au chargement du module : si la DB est down, l'import lui-même rejette.
await pool.query('SELECT 1');
export async function findUser(id: string) {
const { rows } = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
return rows[0];
}⚠️ Coût caché du top-level await : un TLA dans un module rend async tout le graphe qui en dépend. Un CJS ne pourra plus le
require()(ERR_REQUIRE_ASYNC_MODULE), et l'ordre d'exécution des autres modules attend la résolution. En lib publiée, préfère une factory async (export async function connect()) ; réserve le TLA aux entrypoints d'app.
Champ exports dual package
{
"name": "@acme/my-lib",
"version": "1.0.0",
"type": "module",
"main": "./dist/cjs/index.cjs",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs",
"default": "./dist/esm/index.js"
},
"./client": {
"types": "./dist/types/client.d.ts",
"browser": "./dist/browser/client.js",
"import": "./dist/esm/client.js",
"require": "./dist/cjs/client.cjs"
},
"./package.json": "./package.json"
},
"files": ["dist"]
}⚠️ L'ordre des clés dans
exportscompte. Node prend la première qui matche le contexte.typesdoit être en premier pour que TypeScript le voie.
import.meta.url et l'équivalent de __dirname en ESM
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Plus récent (Node 20.11+ / 21.2+) : import.meta.dirname et import.meta.filename existent nativement
console.log(import.meta.dirname); // équivalent de __dirname
console.log(import.meta.filename); // équivalent de __filenameDynamic import — la seule passerelle CJS → ESM (avant Node 22)
// Fichier CJS qui consomme une lib ESM-only
async function main() {
const { default: chalk } = await import('chalk'); // chalk@5 est ESM-only
console.log(chalk.green('hello'));
}
main();Exécuter du TypeScript sans build (Node 22+)
# Strip-types natif : retire les annotations, n'exécute pas les transformations
# (pas de decorators, pas d'enum, pas de namespace)
node --experimental-strip-types src/index.ts
# Node 24+ : par défaut, sans flag
node src/index.tsCJS → ESM cheatsheet
// CJS
const { foo } = require('./bar');
module.exports = { foo };
module.exports.foo = foo;
__dirname; __filename;
require.resolve('pkg');
// ESM équivalent
import { foo } from './bar.js'; // .js obligatoire !
export { foo };
export const foo = 42;
import.meta.dirname; import.meta.filename; // Node 20.11+ / 21.2+
import.meta.resolve('pkg'); // sync, retourne URLAuto-detect TS sans flag (Node 22.6+)
À partir de Node 22.6, le flag --experimental-strip-types est déjà activé par défaut sur les fichiers .ts (mais émet un warning expérimental). Node 24 stabilise. Pour les .mts et .cts, ça marche pareil avec la sémantique ESM/CJS associée.
🎯 Patterns courants — 6
1. Dual package — éviter le "dual package hazard"
Si tu publies à la fois CJS et ESM, deux instances de ta lib peuvent coexister en mémoire (une chargée par require, l'autre par import). Singleton cassé, instanceof qui retourne false, état partagé qui diverge.
Solutions :
- Publie uniquement ESM si tu peux (Node 22+ supporte
require(esm)synchrone). - Si tu fais du dual, mets la logique dans une variante (CJS) et fais que l'autre (ESM) réexporte depuis la première via dynamic import. Ou utilise un fichier
.d.tspartagé. - Évite les classes/symbols qui voyagent entre les deux mondes.
2. Conditional exports avancés (node-addons, worker, development)
{
"exports": {
".": {
"node-addons": "./native/index.cjs",
"node": {
"import": "./node-esm/index.js",
"require": "./node-cjs/index.cjs"
},
"browser": "./browser/index.js",
"deno": "./deno/index.js",
"bun": "./bun/index.js",
"default": "./dist/index.js"
}
}
}Tu peux aussi exposer des conditions personnalisées via node --conditions=development.
3. Subpath patterns (wildcards)
{
"exports": {
"./icons/*": "./dist/icons/*.js",
"./icons/*.svg": "./assets/icons/*.svg"
}
}Permet import HomeIcon from '@acme/ui/icons/home' sans devoir lister chaque fichier.
4. Garder une CLI compatible CJS et ESM
Pour qu'une CLI fonctionne quelle que soit l'option de l'utilisateur :
// bin/cli.js — shebang #!/usr/bin/env node
#!/usr/bin/env node
import('./esm-entry.js').catch((err) => {
console.error(err);
process.exit(1);
});Le fichier bin/cli.js reste en CJS minimal, et lazy-load l'ESM par dynamic import.
5. TypeScript : choisir entre tsx, ts-node, swc, et --strip-types
| Outil | Vitesse | Decorators | Enum | Production ? |
|---|---|---|---|---|
node --strip-types (22+) | Très rapide | Non | Non | Oui (sur Node 24+) |
tsx | Très rapide | Oui | Oui | Dev / build pipelines |
swc-node | Très rapide | Oui | Oui | Tests CI |
ts-node | Lent | Oui | Oui | Legacy, à éviter pour nouveaux projets |
Recommandation 2026 : tsx en dev pour la souplesse, tsc --emitDeclarationOnly + tsup/esbuild pour publier, ou --strip-types natif si ton code reste dans le sous-ensemble "erasable TS" (pas de decorators, pas d'enum, pas de namespace).
6. package.json minimal pour une lib ESM-first publiable en 2026
{
"name": "@acme/lib",
"version": "1.0.0",
"type": "module",
"engines": { "node": ">=20" },
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": ["dist"],
"sideEffects": false
}Pas de main, pas de module, pas de variante CJS. Tout passe par exports. sideEffects: false permet aux bundlers de tree-shaker agressivement.
🔄 Versions — Node 18 / 20 / 22 / 24
Node 18 — ESM stable,
exportschamp stable. Pas derequire(esm): pour consommer une lib ESM-only depuis du CJS, seulawait import(...)fonctionne.import.meta.urlmais pasimport.meta.dirname.Node 20 —
import.meta.resolve()stable (sync, retourne une URL). Loaders ESM via--experimental-loader(utilisé partsx).import attributes(anciennementassert) pour JSON :import data from './x.json' with { type: 'json' };(avec--experimental-import-attributes).Node 22 —
require(esm): un CJS peutrequire()un module ESM synchrone (sans top-level await). Derrière--experimental-require-modulede 22.0 à 22.11, puis activé par défaut dès 22.12 (la ligne LTS). Énorme pour la transition.import.meta.dirnameetimport.meta.filenamestables (déjà dispo depuis 20.11).--experimental-strip-typesarrive (22.6, activé par défaut en 22.7) : exécution de TS sans transformation.Node 24 —
require(esm)activé par défaut sans flag.--experimental-strip-typesstable et activé par défaut (Node 24 reconnaît.tsnativement).import.meta.resolvetotalement stable. Les loaders custom passent à l'APImodule.registerstandardisée. Tu peux écrireimport './x.ts'et Node sait quoi faire.
🧭 Décision matérielle : si tu démarres un projet en 2026, vise Node 22 LTS minimum, mets
"type":"module", publie un seul format (ESM) avecexports, et utilise--strip-typesoutsxpour le dev.
⚠️ Pitfalls — 10
__dirname/__filenamen'existent pas en ESM — utiliseimport.meta.dirname(Node 20.11+) oufileURLToPath(import.meta.url).Pas d'extension dans l'import — en ESM,
import x from './foo'échoue. Tu dois écrire./foo.js. Même si tu écris du TS, mets.js(l'import fait référence au build final).required'un module avec top-level await —require(esm)échoue avecERR_REQUIRE_ASYNC_MODULE. Solution : exporter une factory async plutôt qu'utiliser TLA."main"ignoré dès que"exports"existe — beaucoup de gens laissent les deux et sont surpris. Si tu metsexports, c'est lui qui décide.mainne sert que pour les anciens outils.Path non listé dans
exports—import '@lib/internal/foo'échoue avecERR_PACKAGE_PATH_NOT_EXPORTEDsi tu n'as pas exposé./internal/foo. C'est voulu (encapsulation).typesqui ne fonctionne pas avec TypeScript < 4.7 — TS résout les types viaexportsseulement depuis 4.7 avecmoduleResolution: "node16" | "nodenext" | "bundler". Sinon il retombe surtypes/typingsracine.Default export ESM importé en CJS —
const lib = require('esm-lib')te donne{ default: ... }, pas la fonction directement. Tu dois fairerequire('esm-lib').default. C'est pourquoidefault: x(sans nommé) est moins safe pour la dual interop.Cyclic dependencies — ESM les supporte mieux que CJS (les imports sont des "live bindings"), mais une dépendance circulaire avec top-level await bloque indéfiniment.
import.meta.urldans un test bundlé — Jest avectransform: 'ts-jest'ne supporte pasimport.metapar défaut. Solutions : passer envitest, ou utilisernode --test.Importer du JSON — en ESM,
import data from './x.json'nécessitewith { type: 'json' }(Node 22+ stable). En CJS,require('./x.json')fonctionne directement.tsxvsts-nodeconfigs incompatibles —ts-nodelittsconfig.jsonstrictement,tsxignore une bonne partie. Choisis un outil et tiens-toi-y.
🧪 Testing — node --test
// modules.test.ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
test('import.meta.url donne le bon path', () => {
const __filename = fileURLToPath(import.meta.url);
assert.ok(__filename.endsWith('modules.test.ts'));
const __dirname = dirname(__filename);
assert.ok(__dirname.length > 0);
});
test('dynamic import d’un module ESM', async () => {
const mod = await import('node:path');
assert.equal(typeof mod.join, 'function');
});
test('JSON import via attributes', async () => {
// Nécessite Node 22+
const pkg = await import('../package.json', { with: { type: 'json' } });
assert.equal(typeof pkg.default.name, 'string');
});Lancement :
node --test --experimental-strip-types --experimental-import-attributes modules.test.ts
# Sur Node 24+ : aucun flag nécessaire
node --test modules.test.ts🎬 Cas d'usage concrets
Scénario 1 — Monorepo cabinet juridique TypeScript pur ESM
Un cabinet construit une plateforme interne (apps Next.js, API NestJS, jobs CLI d'ingestion DPA) en monorepo pnpm workspaces. Décision : 100 % ESM, TypeScript Node-Next ("module": "NodeNext", "moduleResolution": "NodeNext"). Les imports relatifs portent l'extension .js (résolue par TS au build, par Node au runtime via tsx ou compilation tsc).
Avantages : tree-shaking effectif côté Next.js, top-level await dans les scripts d'init (chargement de la grille tarifaire client avant export), aucun double système. Les libs publiées en interne (@cabinet/contracts-parser, @cabinet/rgpd-policies) exposent un "exports" strict, sans "main", ce qui force tous les consumers à passer par les chemins déclarés (encapsulation).
Le piège rencontré : pdf-parse (CJS legacy) ne fournit pas de default export ESM propre. Solution : wrapper interne import pdfParse from "pdf-parse" exposé via une lib @cabinet/pdf qui réexporte proprement (export default (await import("pdf-parse")).default).
Scénario 2 — E-commerce package dual CJS + ESM publié sur npm
Une plateforme e-commerce open-source un SDK client (@shop/sdk) consommé par : leur propre Next.js (ESM), des intégrations partenaires legacy Express (CJS), un script Webpack 4 d'un revendeur (CJS strict).
Le package.json doit servir les deux mondes :
{
"name": "@shop/sdk",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}Build via tsup --format esm,cjs --dts. Tests CI exécutent un smoke test d'import dans les deux modes (node -e "require('@shop/sdk')" et node --input-type=module -e "import('@shop/sdk')"). Sans ce double check, une régression top-level await dans le code source casse silencieusement le bundle CJS.
Scénario 3 — Lib RH partagée (logique paie) en ESM avec ESM-only deps
SaaS RH (paie + congés) : la logique de calcul de bulletin est isolée dans @hr/payroll-core, consommée par l'API (NestJS), un worker BullMQ, et un export PDF Lambda. La lib utilise date-fns v3 (ESM-only) et zod (dual).
Décision : tout en ESM. Le projet utilise "imports" du package.json pour les chemins internes (#config, #schemas/*) — ça évite les ../../../ et reste résolu par Node nativement. Top-level await dans le bootstrap pour charger la convention collective applicable depuis Postgres avant de figer les barèmes en mémoire (lecture seule, immutable).
Migration depuis l'ancien code CJS faite en 2 PR : (1) ajout "type": "module" + renommage scripts en .mjs transitoirement, (2) bascule TS "module": "NodeNext" et nettoyage. Pas de mode mixte intermédiaire — éviter le piège du double système qui finit toujours en confusion.
🛠️ Exemple end-to-end
Package dual CJS+ESM avec types corrects pour les deux mondes, plus consommation depuis ESM et CJS.
// packages/sdk/package.json
{
"name": "@shop/sdk",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./errors": {
"types": "./dist/errors.d.ts",
"import": "./dist/errors.mjs",
"require": "./dist/errors.cjs"
},
"./package.json": "./package.json"
},
"files": ["dist"],
"scripts": {
"build": "tsup src/index.ts src/errors.ts --format esm,cjs --dts --clean",
"test:cjs": "node -e \"const s = require('./dist/index.cjs'); s.createClient({apiKey:'x'}).health()\"",
"test:esm": "node --input-type=module -e \"import('./dist/index.mjs').then(s => s.createClient({apiKey:'x'}).health())\""
}
}// packages/sdk/src/index.ts
import { SdkError } from "./errors.js"; // extension .js obligatoire en NodeNext
export type ClientOptions = { apiKey: string; baseUrl?: string };
export function createClient(opts: ClientOptions) {
if (!opts.apiKey) throw new SdkError("apiKey required");
const base = opts.baseUrl ?? "https://api.shop.example";
return {
async health() {
const res = await fetch(`${base}/health`, {
headers: { authorization: `Bearer ${opts.apiKey}` },
});
if (!res.ok) throw new SdkError(`health ${res.status}`);
return (await res.json()) as { ok: boolean };
},
async listProducts(query: string) {
const url = new URL(`${base}/products`);
url.searchParams.set("q", query);
const res = await fetch(url, {
headers: { authorization: `Bearer ${opts.apiKey}` },
});
if (!res.ok) throw new SdkError(`products ${res.status}`);
return (await res.json()) as Array<{ id: string; name: string }>;
},
};
}
export { SdkError };// packages/sdk/src/errors.ts
export class SdkError extends Error {
constructor(message: string) {
super(message);
this.name = "SdkError";
}
}Consommation depuis un projet ESM moderne (Next.js, NestJS récent) :
import { createClient } from "@shop/sdk";
const sdk = createClient({ apiKey: process.env.SHOP_KEY! });
console.log(await sdk.health());Consommation depuis un projet CJS legacy (Express historique) :
const { createClient } = require("@shop/sdk");
const sdk = createClient({ apiKey: process.env.SHOP_KEY });
sdk.health().then(console.log);Points clés : "exports" strict (pas de "main" seul), conditions import/require/types correctement ordonnées (types en premier), build dual via tsup, smoke tests CI sur les deux résolutions pour bloquer les régressions silencieuses.
🔁 Quand utiliser / éviter
| Choix | Utiliser quand | Éviter quand |
|---|---|---|
| ESM pur | Nouveau projet, Node ≥ 20, target moderne | Lib qui doit cibler Node 14 ou avant |
| Dual package | Public mixte (Node ancien + bundlers + Deno) | Si tu peux te permettre ESM seul (la majorité en 2026) |
require(esm) | Migrer progressivement un gros monorepo CJS | Code avec top-level await (incompatible) |
--strip-types natif | Scripts simples, projets sans enum/decorators | Codebase TypeScript avec features avancées |
tsx | Dev experience, scripts, monorepos | Production runtime (compile au build) |
import.meta.resolve | Résoudre un module sans l'importer | Compatibilité avec bundlers (pas tous le supportent) |
🧬 Aller plus loin
Algorithme de résolution Node — version simplifiée
Quand tu écris import x from 'foo/bar', Node :
- Identifie le package : remonte les
node_modulesjusqu'à trouverfoo/package.json. - Lit
exportssi présent. Sinon, fallback surmain(CJS) oumodule(legacy bundlers). - Cherche le subpath
./bardansexports. Si pas trouvé →ERR_PACKAGE_PATH_NOT_EXPORTED. - Évalue les conditional exports dans l'ordre des clés du
package.json: prend la première qui matche (node,import,require,default, etc.). - Résout le chemin final et tente le chargement.
- Détermine le mode :
.mjs→ ESM,.cjs→ CJS,.js→ selon"type"du package containing.
Subpath imports — alias interne au package
Tu peux définir des alias internes pour ton propre package (utile pour éviter ../../) :
{
"name": "my-app",
"imports": {
"#config": "./src/config/index.js",
"#utils/*": "./src/utils/*.js"
}
}// Depuis n'importe quel fichier de ton package
import { dbUrl } from '#config';
import { sleep } from '#utils/sleep';Pas besoin de tsconfig.paths + bundler magic : c'est natif Node.
import.meta.resolve — résolution sans import
import { fileURLToPath } from 'node:url';
import { Worker } from 'node:worker_threads';
// Résoudre un module sans l'exécuter → renvoie une URL string
const url = import.meta.resolve('chalk');
console.log(url); // file:///path/to/node_modules/chalk/source/index.js
// new Worker accepte directement une URL file:// — pas besoin de "défaire" le préfixe
const worker = new Worker(new URL(import.meta.resolve('./worker.js')));
// Si une API attend un *chemin* OS (pas une URL), convertis proprement :
const path = fileURLToPath(import.meta.resolve('./worker.js'));❌ N'utilise jamais
.replace('file://', '')pour transformer une URL en chemin : ça casse sur Windows (file:///C:/...) et sur les chemins avec espaces/caractères encodés (%20).fileURLToPathest la seule conversion correcte.
Synchrone, stable depuis Node 20+.
Loaders ESM custom (module.register)
Tu peux intercepter la résolution et le chargement des modules :
// loader.mjs
export function resolve(specifier, context, nextResolve) {
if (specifier === 'magic') {
return { url: 'data:text/javascript,export default 42', shortCircuit: true };
}
return nextResolve(specifier, context);
}
// main.mjs
import { register } from 'node:module';
register('./loader.mjs', import.meta.url);
const { default: x } = await import('magic');
console.log(x); // 42C'est comme ça que tsx injecte la transformation TypeScript : il register un loader qui intercepte .ts et le transpile en JS à la volée.
Pourquoi chalk@5 est ESM-only ?
Le mainteneur Sindre Sorhus a fait passer beaucoup de ses packages (chalk, got, ora, ...) en ESM-only à partir de 2021. Raisons :
- Pas de dual package hazard.
- Meilleur tree-shaking pour les bundlers.
- Top-level await,
import.meta,import attributesaccessibles. - Pousser l'écosystème à migrer.
Avant Node 22, ça forçait les consommateurs CJS à utiliser await import(...). Depuis Node 22 + require(esm), c'est transparent. C'est pourquoi 2026 est l'année de la migration finale vers ESM dans l'écosystème.
Configuration tsconfig.json recommandée pour ESM Node 22+
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"lib": ["ES2023"],
"types": ["node"]
}
}verbatimModuleSyntax force à utiliser import type pour les imports type-only, ce qui prévient les bugs d'élision. allowImportingTsExtensions + rewriteRelativeImportExtensions (TS 5.7+) te laisse écrire import './foo.ts' (parsé propre par les éditeurs) et le compile en ./foo.js à l'output.
Différence entre tsx, vite-node et node --strip-types
| Outil | Stratégie | Decorators | Enum | Watch mode | Coût boot |
|---|---|---|---|---|---|
tsx | Loader ESM + esbuild | ✅ | ✅ | ✅ | ~50 ms |
vite-node | Vite SSR runtime | ✅ | ✅ | ✅ (HMR) | ~100 ms |
node --strip-types | Type erasure C++ natif | ❌ | ❌ | ❌ | ~0 ms |
swc-node | @swc/core register | ✅ | ✅ | ❌ | ~80 ms |
bun (alternative) | Runtime alternatif | ✅ | ✅ | ✅ | ~10 ms |
Pour des CLI / scripts simples → node --strip-types. Pour du dev d'app avec features TS avancées → tsx. Pour des tests → vitest (qui utilise vite-node sous le capot).
🧠 Comment un staff engineer raisonne là-dessus
Le débat ESM/CJS n'est pas une question de goût : c'est un arbitrage entre compatibilité écosystème et dette de toolchain. Voici la grille mentale.
1. Le format de publication est un contrat API, pas un détail de build. Une fois que tu publies "exports", retirer un subpath ou changer une condition est un breaking change — même si tes types ne bougent pas. Traite exports comme tu traites la signature de tes fonctions publiques : versionne-le, teste-le en CI (smoke import dans les deux modes), et n'expose que ce que tu veux supporter pour toujours. Le ./package.json exporté et le ./internal/* non exporté sont des décisions d'encapsulation aussi importantes que public/private en TS.
2. Le format runtime (ce que Node exécute) ≠ le format source (ce que tu écris) ≠ le format publié. Ces trois axes sont découplés :
| Axe | Question | Outil de décision |
|---|---|---|
| Source | Ce que je tape ? | TS module: NodeNext, .ts avec extensions .js dans les imports |
| Runtime dev | Comment je l'exécute sans build ? | tsx / node --strip-types |
| Publié | Ce que mes consumers reçoivent ? | exports + tsup/tsc |
Confondre ces axes est la source n°1 de confusion junior (« mais pourquoi je dois écrire .js alors que mon fichier est en .ts ? » → parce que l'import référence le runtime/publié, pas la source).
3. Le dual package est une dette, pas une feature. Chaque format dual double la surface de test, introduit le dual package hazard, et masque les régressions TLA. En 2026, la question par défaut est « puis-je publier ESM only ? » et tu ne descends à dual que si un consumer concret et nommé l'exige (Webpack 4 d'un partenaire, plugin Jest legacy). « Au cas où » n'est pas une justification — require(esm) couvre la quasi-totalité des cas CJS→ESM depuis Node 22.
4. Coûts non-fonctionnels à mettre dans la balance :
- Cold start (Lambda/CF) : ESM + tree-shaking + lazy
import()des deps lourdes > CJS monolithique. Mesure avec--cpu-profau boot. - Sécurité :
exportsstrict = surface d'attaque réduite (un attaquant ne peut pasrequire('lib/internal/secret-debug')). Les loaders custom (module.register) s'exécutent avec les pleins privilèges — audite tout loader tiers comme du code de prod. - Observabilité : un échec de résolution doit être lisible.
ERR_PACKAGE_PATH_NOT_EXPORTEDetERR_REQUIRE_ASYNC_MODULEsont attendus — log le specifier + la condition résolue, pas juste la stack.
Mode de défaillance que les seniors anticipent : la régression silencieuse du build CJS. Le code source compile, les types passent, l'ESM marche — mais quelqu'un a ajouté un await top-level, et le .cjs généré jette à l'import chez tous les consumers CJS. Sans smoke test node -e "require(...)" en CI, ça part en prod. Le test d'import est non négociable sur un package dual.
🎤 En entretien
Q : Pourquoi import x from './foo' échoue en ESM Node alors que ça marche avec un bundler ? Node implémente la spec ESM stricte : pas d'extension résolue automatiquement, pas d'index implicite. Les bundlers (Webpack/Vite) appliquent une résolution « façon CJS » par confort. En Node natif, tu dois écrire ./foo.js (l'extension du fichier runtime). C'est volontaire : la résolution déterministe sans I/O spéculatif (pas de « essaie .js, puis .json, puis /index.js »).
Q : C'est quoi le « dual package hazard » et comment l'éviter ? Quand un package est chargé à la fois via require (variante CJS) et import (variante ESM), deux instances distinctes du module coexistent : état non partagé, instanceof faux entre les deux, singletons cassés. On l'évite en publiant ESM only, ou en faisant que la variante ESM réexporte la CJS (une seule source de vérité d'état), ou en gardant le package stateless.
Q : Pourquoi "main" est-il ignoré quand "exports" existe, et qu'est-ce que ça change pour la sécurité ?exports est une allowlist d'encapsulation : Node ne sert que les subpaths déclarés et tombe en ERR_PACKAGE_PATH_NOT_EXPORTED pour tout le reste. main était un simple point d'entrée sans encapsulation (tout node_modules/lib/anything.js était importable). Garder les deux est inoffensif (legacy fallback) mais c'est exports qui fait foi — et qui empêche un consumer d'atteindre tes fichiers internes.
Q : require(esm) (Node 22+) résout-il tous les problèmes de migration ? Non. Il permet à du CJS de require() un module ESM synchrone, mais échoue avec ERR_REQUIRE_ASYNC_MODULE si le module (ou un de ses imports) utilise un top-level await. C'est pourquoi les libs publiées doivent éviter le TLA et exposer des factories async. require(esm) débloque ~90 % des cas, pas 100 %.
🏋️ Exercices
Exercice 1 — Construire un dual package qui passe les deux smoke tests
Objectif : publier @you/greet exposant greet(name), consommable en import ET en require, avec types corrects des deux côtés.
Étapes : tsup src/index.ts --format esm,cjs --dts, écris un exports avec l'ordre types → import → require, puis ajoute deux scripts CI : node --input-type=module -e "import('@you/greet').then(m => m.greet('x'))" et node -e "require('@you/greet').greet('x')".
Indice/Solution : si le test CJS jette is not a function, vérifie que tu n'exportes pas qu'un default (CJS reçoit { default }). Exporte un named greet. Si les types ne résolvent pas, vérifie moduleResolution: nodenext côté consumer et types en première clé.
Exercice 2 — Reproduire et corriger le dual package hazard
Objectif : provoquer instanceof === false entre les deux variantes, puis le réparer.
Crée une classe Token exportée par le package. Dans un harness, charge la variante CJS (require) et la variante ESM (import()), crée une instance via l'une, teste instanceof avec la classe de l'autre → false.
Indice/Solution : la cause est deux modules chargés deux fois. Répare en faisant que index.mjs ne contienne aucune logique mais réexporte index.cjs via await import()/interop, OU passe ESM-only et consomme le CJS via require(esm). Constate que instanceof redevient true.
Exercice 3 — Casser un build CJS avec un top-level await, puis le diagnostiquer
Objectif : comprendre ERR_REQUIRE_ASYNC_MODULE et le rendre détectable en CI.
Ajoute un await fetch(...) top-level dans src/index.ts. L'ESM marche, mais node -e "require('./dist/index.cjs')" jette. Refactore en factory export async function init() pour que les deux mondes remarchent.
Indice/Solution : le TLA contamine tout le graphe. La règle : aucun TLA dans le code d'une lib publiée. Ajoute une règle ESLint (no-restricted-syntax sur AwaitExpression au top-level d'un fichier de lib) pour bloquer la régression.
Exercice 4 — Subpath patterns + imports internes
Objectif : exposer @you/ui/icons/<name> via wildcard, et utiliser #config en interne sans ../../.
Configure "exports": { "./icons/*": "./dist/icons/*.js" } et "imports": { "#config": "./dist/config.js" }. Vérifie que import Home from '@you/ui/icons/home' marche et que @you/ui/icons/internal (non publié) jette ERR_PACKAGE_PATH_NOT_EXPORTED.
Indice/Solution : les wildcards * ne sont pas du glob — un seul * substitué littéralement. Pour TS, ajoute "customConditions" / nodenext afin que l'éditeur résolve aussi les #imports.
Exercice 5 (production-grade) — Loader custom module.register pour mapper un alias
Objectif : écrire un loader ESM qui résout @app/* vers ./src/*.ts à la volée, sans bundler.
Implémente resolve(specifier, context, nextResolve) qui réécrit le specifier, register-le via register('./loader.mjs', import.meta.url), et exécute un fichier qui importe @app/util.
Indice/Solution : retourne { url, shortCircuit: true } une fois la cible trouvée, sinon délègue à nextResolve. Attention : les loaders tournent dans un thread séparé depuis Node 20 — pas d'état partagé avec le main. Pour la prod, préfère imports/exports natifs ; le loader custom ne se justifie que pour de la transformation (TS, codegen).
Exercice 6 (break-then-fix) — Migration d'un mini-monorepo CJS → ESM
Objectif : migrer 3 packages (core ← api ← cli, tous CJS) vers ESM sans casser les consumers à chaque étape.
Convertis core d'abord en dual, vérifie que api/cli (encore CJS) le consomment via require(esm), puis remonte le graphe, et enfin drop le dual pour ESM-only.
Indice/Solution : ne flippe jamais "type":"module" à la racine en premier — ça casse tous les .js CJS d'un coup. Feuilles → racine. À chaque PR, lance la suite de smoke imports. Si tu utilises Jest, bascule en vitest/node --test avant la migration ESM, pas pendant.
🔗 Liens
- Doc
exportsfield : https://nodejs.org/api/packages.html#exports - Conditional exports : https://nodejs.org/api/packages.html#conditional-exports
require(esm)(Node 22+) : https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require--experimental-strip-types: https://nodejs.org/api/typescript.htmltsx: https://tsx.is/- TypeScript
moduleResolution: "node16"|"nodenext": https://www.typescriptlang.org/docs/handbook/modules/reference.html - "Pure ESM packages" gist (Sindre Sorhus) : https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c
module.register(loaders custom) : https://nodejs.org/api/module.html#moduleregisterspecifier-parenturl-options- Article Andrea Giammarchi — "Node and the slow ESM transition"
- Article Dr. Axel Rauschmayer — "ECMAScript modules in Node.js"
Migration progressive d'un monorepo CJS vers ESM
Stratégie en 4 phases pour un gros monorepo :
- Toolchain en ESM d'abord : passe tes scripts de build (
scripts/build.ts), tests, lint en ESM. Les fichiers.mjscohabitent avec.jsCJS. - Packages feuilles d'abord : convertis les packages sans dépendances internes. Tu publies un dual package (
exportsavecimport+require) si tu as encore des consommateurs CJS dans le monorepo. - Remonte le graphe de dépendances : convertis les packages intermédiaires. À chaque étape, les consommateurs CJS continuent de marcher via
require(esm)(Node 22+). - Apps en dernier : flippe le
"type":"module"racine. Drop le dual package, garde ESM only.
⚠️ Si tu utilises Jest, ESM est encore expérimental dans Jest en 2026. Migre vers
vitestounode --testen parallèle de la migration ESM.
import cyclique — comportement en ESM vs CJS
// a.ts (ESM)
import { b } from './b.js';
export const a = 'A';
console.log('a.ts:', b);
// b.ts (ESM)
import { a } from './a.js';
export const b = 'B';
console.log('b.ts:', a);En ESM, import crée des live bindings : si a est défini après l'import (top-down execution), la référence est résolue dynamiquement. Tu peux donc avoir des cycles qui marchent — sauf si tu lis la valeur avant qu'elle ne soit assignée (TDZ).
En CJS, require('./a') retourne module.exports à l'instant de l'appel. Dans un cycle, ça peut être un objet partiellement rempli. Plus piégeux.
Lazy imports pour startup time
// Chargement éager : ralentit le boot
import { heavyLib } from 'heavy-lib';
function rarelyUsed() {
return heavyLib.compute();
}
// Chargement lazy : import à la première utilisation
let _heavyLib: typeof import('heavy-lib') | null = null;
async function rarelyUsed() {
_heavyLib ??= await import('heavy-lib');
return _heavyLib.compute();
}Pour les CLI, les Lambda, les Cloud Functions où le cold start compte, c'est un levier énorme. Combiné à --experimental-snapshot (Node 22+), tu peux pré-chauffer un snapshot du V8 heap.