Node.js ESM移行の深淵:CommonJSとの共存と「デュアルパッケージ」の真実
Node.jsにおけるES Modules (ESM) への移行は、単なる構文の書き換えではない。それは、過去10年間にわたってCommonJS (CJS) が築き上げてきた「動的な解決」というエコシステムを、静的解析可能な現代的なモジュールグラフへと強制的に移行させる、システム設計の外科手術だ。
本稿では、多くのチームが「`ERR_REQUIRE_ESM`」というエラーに絶望する前に知っておくべき、ハイブリッドパッケージ構築の極意を伝授する。
—
1. 相互運用の「境界線」を理解する
CommonJSとESMは、単に「ロード方法が違う」のではない。評価タイミングとコンテキストが根本から異なる。
- CommonJS: 同期的。`require`は実行時に解決され、キャッシュへ即時反映される。
- ESM: 非同期。`import`はトップレベルでの静的解析が必須であり、`__dirname`や`__filename`といったCJS特有のグローバル変数は存在しない。
この断絶を乗り越えるために、我々アーキテクトが採るべき戦略は「デュアルパッケージ(Dual Package)」の構築である。
—
2. package.json による「真のモジュール制御」
単に `type: “module”` を設定するだけでは、ライブラリの利用者は路頭に迷う。真のプロは `exports` フィールドを使い、コンシューマーの環境に合わせて最適なエントリポイントを動的に切り替える。
以下は、TypeScript環境でCJS/ESM両対応を実現するための `package.json` のベストプラクティス構成だ。
{
“name”: “my-awesome-lib”,
“version”: “1.0.0”,
“type”: “module”,
“exports”: {
“.”: {
“import”: “./dist/esm/index.js”, // ESM環境用(Node 12+ / Modern Bundlers)
“require”: “./dist/cjs/index.cjs” // CJS環境用(Node 10+ / Legacy)
}
},
“main”: “./dist/cjs/index.cjs”, // 下位互換性確保のためのフォールバック
“module”: “./dist/esm/index.js” // バンドラ用エントリポイント
}
なぜ `exports` が重要なのか
`exports` フィールドは「カプセル化」を強制する。これにより、利用者が `require(‘my-awesome-lib/dist/internal/private’)` のように内部構造を勝手に参照することを防ぎ、セマンティックバージョニングを維持したまま内部リファクタリングが可能になる。
—
3. 実務を加速させる「神・開発環境」の構築
ESMへの移行で最も時間が溶けるのは、「修正→ビルド→テスト」のフィードバックループだ。これを極限まで速めるための環境構築を解説する。
おすすめの神ツール:`tsx`
`ts-node` のような重厚長大なツールは捨てろ。`tsx` を導入せよ。これは `esbuild` をベースにしており、TypeScript/ESMをネイティブかつ爆速で実行できる。
インストール
npm install -D tsx
開発用スクリプト(package.jsonに追加)
“dev”: “tsx watch src/index.ts”
VSCode 設定の最適化
チーム開発において、ESMとCJSの混在による混乱をIDE側で検知させるには、`.vscode/settings.json` の共有が不可欠だ。
{
// プロジェクト全体で一貫したモジュール解決を強制
“typescript.tsdk”: “node_modules/typescript/lib”,
“javascript.validate.enable”: true,
// Node.jsのESM解決ルールをIDEに認識させる
“typescript.preferences.importModuleSpecifier”: “non-relative”,
“editor.codeActionsOnSave”: {
“source.fixAll.eslint”: “explicit”
}
}
—
4. チーム開発における「アーキテクトの戒律」
移行プロジェクトでチームが疲弊するのは、移行の「目的」と「ルール」が共有されていないからだ。次の3つのルールをコミット前のチェックリストに加えよ。
1. 拡張子の明示: ESMでは `import { foo } from ‘./foo.js’` と、拡張子を記述することが必須だ。これがないとランタイムがモジュール解決に失敗する。
2. トップレベル await の利用: ESMの特権を活かせ。非同期初期化が必要なライブラリなら、`await` を使ってブートストラップを簡素化せよ。
3. CJSファイルは `.cjs` 拡張子へ: 混在環境では、Node.jsのランタイムが混乱しないよう、CJSファイルには必ず `.cjs` を付与し、`type: “module”` との境界を明確にする。
—
アーキテクトからの提言:移行を「進化」に変える
ESMへの移行を単なる「タスク」と捉えてはいけない。これは、あなたのコードベースを 「次世代のランタイム(DenoやBunなど)でもそのまま動くポータブルな資産」 に昇華させるチャンスだ。
古い `require` に執着することは、未来の技術的負債を抱え続けることに他ならない。`exports` による明確なインターフェース設計を行い、`tsx` で開発体験を最大化せよ。
我々エンジニアが書くのはコードではない。「未来のプロジェクトが快適に走るためのインフラ」 なのだ。今日から、その設計思想をコードに刻んでいこう。