Skip to content

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 exports avec des conditional exports pour gérer les variantes (import/require/types/browser), et profite de require(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 avec tsc/tsup, soit tu exécutes directement .ts via tsx (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 → ENOTFOUND

Analogie : 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

ts
// 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

json
{
  "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 exports compte. Node prend la première qui matche le contexte. types doit être en premier pour que TypeScript le voie.

import.meta.url et l'équivalent de __dirname en ESM

ts
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 __filename

Dynamic import — la seule passerelle CJS → ESM (avant Node 22)

ts
// 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+)

bash
# 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.ts

CJS → ESM cheatsheet

ts
// 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 URL

Auto-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.ts partagé.
  • Évite les classes/symbols qui voyagent entre les deux mondes.

2. Conditional exports avancés (node-addons, worker, development)

json
{
  "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)

json
{
  "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 :

ts
// 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

OutilVitesseDecoratorsEnumProduction ?
node --strip-types (22+)Très rapideNonNonOui (sur Node 24+)
tsxTrès rapideOuiOuiDev / build pipelines
swc-nodeTrès rapideOuiOuiTests CI
ts-nodeLentOuiOuiLegacy, à é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

json
{
  "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, exports champ stable. Pas de require(esm) : pour consommer une lib ESM-only depuis du CJS, seul await import(...) fonctionne. import.meta.url mais pas import.meta.dirname.

  • Node 20import.meta.resolve() stable (sync, retourne une URL). Loaders ESM via --experimental-loader (utilisé par tsx). import attributes (anciennement assert) pour JSON : import data from './x.json' with { type: 'json' }; (avec --experimental-import-attributes).

  • Node 22require(esm) : un CJS peut require() un module ESM synchrone (sans top-level await). Derrière --experimental-require-module de 22.0 à 22.11, puis activé par défaut dès 22.12 (la ligne LTS). Énorme pour la transition. import.meta.dirname et import.meta.filename stables (déjà dispo depuis 20.11). --experimental-strip-types arrive (22.6, activé par défaut en 22.7) : exécution de TS sans transformation.

  • Node 24require(esm) activé par défaut sans flag. --experimental-strip-types stable et activé par défaut (Node 24 reconnaît .ts nativement). import.meta.resolve totalement stable. Les loaders custom passent à l'API module.register standardisée. Tu peux écrire import './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) avec exports, et utilise --strip-types ou tsx pour le dev.

⚠️ Pitfalls — 10

  1. __dirname / __filename n'existent pas en ESM — utilise import.meta.dirname (Node 20.11+) ou fileURLToPath(import.meta.url).

  2. 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).

  3. require d'un module avec top-level awaitrequire(esm) échoue avec ERR_REQUIRE_ASYNC_MODULE. Solution : exporter une factory async plutôt qu'utiliser TLA.

  4. "main" ignoré dès que "exports" existe — beaucoup de gens laissent les deux et sont surpris. Si tu mets exports, c'est lui qui décide. main ne sert que pour les anciens outils.

  5. Path non listé dans exportsimport '@lib/internal/foo' échoue avec ERR_PACKAGE_PATH_NOT_EXPORTED si tu n'as pas exposé ./internal/foo. C'est voulu (encapsulation).

  6. types qui ne fonctionne pas avec TypeScript < 4.7 — TS résout les types via exports seulement depuis 4.7 avec moduleResolution: "node16" | "nodenext" | "bundler". Sinon il retombe sur types/typings racine.

  7. Default export ESM importé en CJSconst lib = require('esm-lib') te donne { default: ... }, pas la fonction directement. Tu dois faire require('esm-lib').default. C'est pourquoi default: x (sans nommé) est moins safe pour la dual interop.

  8. 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.

  9. import.meta.url dans un test bundlé — Jest avec transform: 'ts-jest' ne supporte pas import.meta par défaut. Solutions : passer en vitest, ou utiliser node --test.

  10. Importer du JSON — en ESM, import data from './x.json' nécessite with { type: 'json' } (Node 22+ stable). En CJS, require('./x.json') fonctionne directement.

  11. tsx vs ts-node configs incompatiblests-node lit tsconfig.json strictement, tsx ignore une bonne partie. Choisis un outil et tiens-toi-y.

🧪 Testing — node --test

ts
// 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 :

bash
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 :

json
{
  "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.

json
// 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())\""
  }
}
ts
// 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 };
ts
// 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) :

ts
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) :

js
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

ChoixUtiliser quandÉviter quand
ESM purNouveau projet, Node ≥ 20, target moderneLib qui doit cibler Node 14 ou avant
Dual packagePublic mixte (Node ancien + bundlers + Deno)Si tu peux te permettre ESM seul (la majorité en 2026)
require(esm)Migrer progressivement un gros monorepo CJSCode avec top-level await (incompatible)
--strip-types natifScripts simples, projets sans enum/decoratorsCodebase TypeScript avec features avancées
tsxDev experience, scripts, monoreposProduction runtime (compile au build)
import.meta.resolveRésoudre un module sans l'importerCompatibilité 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 :

  1. Identifie le package : remonte les node_modules jusqu'à trouver foo/package.json.
  2. Lit exports si présent. Sinon, fallback sur main (CJS) ou module (legacy bundlers).
  3. Cherche le subpath ./bar dans exports. Si pas trouvé → ERR_PACKAGE_PATH_NOT_EXPORTED.
  4. Évalue les conditional exports dans l'ordre des clés du package.json : prend la première qui matche (node, import, require, default, etc.).
  5. Résout le chemin final et tente le chargement.
  6. 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 ../../) :

json
{
  "name": "my-app",
  "imports": {
    "#config": "./src/config/index.js",
    "#utils/*": "./src/utils/*.js"
  }
}
ts
// 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

ts
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). fileURLToPath est 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 :

ts
// 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); // 42

C'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 attributes accessibles.
  • 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+

json
{
  "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

OutilStratégieDecoratorsEnumWatch modeCoût boot
tsxLoader ESM + esbuild~50 ms
vite-nodeVite SSR runtime✅ (HMR)~100 ms
node --strip-typesType 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 :

AxeQuestionOutil de décision
SourceCe que je tape ?TS module: NodeNext, .ts avec extensions .js dans les imports
Runtime devComment 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-prof au boot.
  • Sécurité : exports strict = surface d'attaque réduite (un attaquant ne peut pas require('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_EXPORTED et ERR_REQUIRE_ASYNC_MODULE sont 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 typesimportrequire, 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 (coreapicli, 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

Migration progressive d'un monorepo CJS vers ESM

Stratégie en 4 phases pour un gros monorepo :

  1. Toolchain en ESM d'abord : passe tes scripts de build (scripts/build.ts), tests, lint en ESM. Les fichiers .mjs cohabitent avec .js CJS.
  2. Packages feuilles d'abord : convertis les packages sans dépendances internes. Tu publies un dual package (exports avec import + require) si tu as encore des consommateurs CJS dans le monorepo.
  3. Remonte le graphe de dépendances : convertis les packages intermédiaires. À chaque étape, les consommateurs CJS continuent de marcher via require(esm) (Node 22+).
  4. 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 vitest ou node --test en parallèle de la migration ESM.

import cyclique — comportement en ESM vs CJS

ts
// 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

ts
// 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.

Bibliothèque tech perso — Achref