Node.jsにおけるデュアルCJS/ESMビルドの終焉
Node.jsはrequire(esm)をサポートし、多くのライブラリでESM専用が標準になりました。CJSをやめる条件、top-level awaitの注意点、安全な移行を解説します。
2026年6月時点で、サポートされているすべてのNode.jsリリースがESモジュールをrequire()できるようになりました。これにより、多くのライブラリがデュアルCommonJS/ESMビルドを提供してきた唯一の理由が解消されます。増加し続けるパッケージの大部分において、ESM専用が今やデフォルトとして正しい選択となっています。10年にわたるパッケージングの苦痛を定義してきた非対称性、つまりCommonJSがESMの世界から何もimportもrequireもできなかった問題は、現在ターゲットとすべきいかなるランタイムにおいても成立しなくなりました。デュアルexportsマップ、tsup/unbuildの並行出力、.d.cts/.d.ts宣言ファイルの管理といった仕組みのほとんどは、Node.jsがコアで解決した問題に対処するために存在していたものです。
本記事は、旧来のデュアルビルドガイドでは語れなかった2026年時点の議論を提示します。非対称性がいつ消滅したかの正確なバージョンタイムライン、require(esm)が実際に何をするのかとその唯一のハード制限、そしてCJSビルドが依然として必要かどうかの判断フレームワークを解説します。制約は消えたわけではなく、移動したのです。新しい互換性の契約は「2つのフォーマットを提供する」ではなく、「同期的なロードパスをトップレベルawaitから解放する」ことです。
重要なポイント
- Node.js 25.4.0(2026年1月19日リリース)の時点で
require(esm)は安定版としてマークされており、同じ変更がアクティブなLTSラインにバックポートされています。つまり、現在サポートされているすべてのNode.jsリリースがESモジュールをrequire()する機能を備えています。 require(esm)はNode 22で--experimental-require-moduleフラグの背後に最初に導入され、Node 23でフラグが外され、LTSにはv22.12.0(2024年12月3日)とv20.19.0でバックポートされ、2025年末に安定版として宣言されました。require(esm)には正確に1つのハード制限があります。トップレベルawaitを使用するESモジュールグラフをロードできないことです。その場合、ERR_REQUIRE_ASYNC_MODULEがスローされ、代わりにimport()を使用するよう指示されます。- ESM専用の作者にとって、
require()でアクセス可能なグラフのどこかに最初のトップレベルawaitを追加することは、すべてのCommonJSコンシューマーにとって破壊的変更となります。semverのメジャーバージョンアップとして扱ってください。 - パッケージがNode 22.12+をターゲットとし、CJSユーザーが
require()するコードでトップレベルawaitを避けているなら、ESM専用での提供が今やデフォルトとして正しい選択です。CJSビルドはv20.19以前のランタイムまたはTLAモジュールのためにのみ維持してください。
デュアルCJS/ESMビルドが存在した理由
デュアルビルドが存在したのは、CommonJSがESモジュールをrequire()できなかったためです。2つのシステムは異なる方法でロードします。require()は同期的であり、呼び出しが完了した瞬間にmodule.exportsを返しますが、ESMは無条件に非同期として扱われていました。同期的な呼び出し元は非同期ロードを待つことができないため、require('some-esm-package')はERR_REQUIRE_ESMをスローしていました。逆方向は常に機能していました。ESMはCommonJSをimportできます。これにより、ライブラリ作者が長年直面してきた非対称な状況が生まれました。モダンなコンシューマー向けにESMを提供し、require()を呼び出すすべての人向けにCommonJSを提供し、条件付きexportsで両方を接続するという方法です。
これは実際のツールオーバーヘッドを意味していました。tsupやunbuildのようなバンドラーは両方のフォーマットを出力し、package.jsonのexportsマップはimportを.mjsエントリに、requireを.cjsエントリにルーティングし、TypeScriptは両方の解決モードで型チェックが行えるよう.d.tsと.d.ctsの宣言ファイルを並置する必要がありました。Anthony Fuの2021年デュアルビルドガイドとMayankの2023年ウォークスルーはこの仕組みを詳細に解説しています。どちらも方法については依然として正確です。ただし、現在のランタイムにおいてはもはや問う必要のない問いに答えているだけです。
デュアルビルドには構造的なリスクもありました。デュアルパッケージハザードです。依存関係グラフがあるパッケージを一方ではimportで、別の場所ではrequireでロードする場合、Nodeは2つの別々のコピー(ESMビルドとCJSビルド)を異なるモジュールインスタンスとしてロードする可能性があります。シングルトン、キャッシュ、レジストリ、またはinstanceofチェックは、2つの乖離した状態を見ることになります。相互運用の問題を解決したデュアルビルドが、静かに状態の重複問題を生み出していたのです。
require(esm):非対称性が消滅した正確なバージョン
Discover how at OpenReplay.com.
この修正は、長年保持されてきた仮定の訂正から生まれました。Nodeコアコントリビューターのジョイー・チョン(Joyee Cheung)が解説しているように、ESM自体は無条件に非同期になるよう設計されていたわけではなく、グラフにトップレベルawaitが含まれる場合にのみ条件付きで非同期になるよう設計されていました。そのため、require()がトップレベルawaitを含まないESMグラフを少なくともサポートするのは自然なことに思えます。この洞察により、(ほとんどの)ESモジュールの同期的なrequire()が可能となり、require(esm)はそれを基に構築されました。
ロールアウトはリリースラインをまたいで段階的に行われました。2026年6月時点のタイムラインは以下の通りです:
| Node.jsライン | require(esm)のステータス | サポートフェーズ(2026年6月) |
|---|---|---|
| 18.x | バックポートなし | EOL — 20+への移行が必要 |
| 20.x | v20.19.0でフラグ解除 | 2026年4月30日にEOL |
| 22.x | v22.12.0(2024年12月3日)でデフォルト有効 | Maintenance LTS |
| 23.x | フラグ解除(非LTS) | EOL |
| 24.x | v24.15.0(2026年4月15日)で安定版マークをバックポート | Active LTS |
| 25.x | v25.4.0(2026年1月19日)で安定版としてマーク | 2026年6月1日にEOL |
| 26.x | 安定版 | Current |
主要な変更点として、v25.4.0リリースにおいて「module: mark require(esm) as stable」(PR #60959)という変更が実験的マーカーを削除し、同じコミットがv24.15.0でLTSラインにバックポートされました。この機能は安定化よりもかなり前にデフォルトでフラグが外されていました。Node 22.12.0がデフォルトで有効になった最初のLTSリリースであり、Node 20ではv20.19.0でバックポートされました。Node 18はバックポートを受けることはありませんでした。
Node.jsのリリーススケジュールによると、2026年6月時点でサポートされているラインは22(Maintenance LTS)、24(Active LTS、アクティブサポートは2026年10月20日まで、その後2028年4月30日までセキュリティメンテナンス)、26(Current)です。3つすべてがフラグ解除の閾値を超えています。Node 18はバックポートを受けず、Node 20は2026年4月30日にEOLを迎えたため、サポートされているプロジェクトがターゲットとすべき最低バージョンにはすでにrequire(esm)が含まれています。
require(esm)がライブラリ作者にもたらす変化
現在のNodeバージョンを使用するCommonJSコンシューマーは、ESM専用パッケージを直接require()できるようになりました。CJSビルドを提供する本来の理由、つまりrequire()の呼び出し元がロックアウトされるという問題は、サポートされているいかなるランタイムにおいても成立しなくなりました。Node.jsのドキュメントが説明しているように、ロードされるESモジュールが要件を満たしている場合、require()はそれをロードしてモジュール名前空間オブジェクトを返すことができます。この場合、動的import()に似ていますが、同期的に実行され、名前空間オブジェクトを直接返します。
これによりデュアルパッケージハザードも解消されます。CommonJSの呼び出し元が並行するCJSコピーではなく実際のESモジュールをロードするようになったため、モジュールインスタンスは1つ、シングルトンは1つ、キャッシュは1つとなります。ビルドが1つしかない場合、慎重なデュアルビルドを正当化していた状態乖離の問題は単純に発生しません。
CJSラッパーを削除する際に重要な相互運用の詳細があります。require(esm)は生の値ではなく名前空間オブジェクトを返すため、デフォルトエクスポートは戻り値そのものではなく.defaultに格納されます。これはimport()が返す結果と同様です。CommonJS形式の単一戻り値が必要な場合、ESモジュールは文字列名"module.exports"を使用して目的の値をエクスポートすることで、require(esm)が直接返す内容をカスタマイズできます。
フォールバックパスが必要な場合、process.features.require_moduleがtrueかどうかを確認することで、実行時にサポートを検出できます。
// 実行時の機能検出 — Node 20.19+、22.12+、および24/26のすべてでtrueになります。
if (process.features.require_module) {
const lib = require("some-esm-only-package");
// デフォルトエクスポートは.defaultにあります
const fn = lib.default ?? lib;
}
唯一の制限:トップレベルawaitが新たな互換性の契約
require(esm)には正確に1つのハード制限があります。トップレベルawaitを使用するESモジュールグラフをロードできないことです。require()は同期的でなければならないため、トップレベルのawaitで自身の評価を一時停止するESMファイルはこの方法でロードできません。require()されるモジュール自体、またはそのモジュールグラフがインポートするモジュールにトップレベルawaitが含まれている場合、ERR_REQUIRE_ASYNC_MODULEがスローされ、代わりにimport()を使用して非同期モジュールをロードするよう指示されます。スローされるメッセージは明確です。「require() cannot be used on an ESM graph with top-level await. Use import() instead.」
重要なのはグラフという言葉です。この制限はrequire()するファイルに関するものではなく、そのファイルが推移的にインポートするすべてのものに関するものです。
実際の事例がその影響範囲を示しています。2026年4月、lru-cache@11.3.0がESMビルドにトップレベルawaitを導入し、lru-cacheのESMビルドを推移的にロードするすべてのCJSモジュールが壊れました。特に影響を受けたのは、@asamuzakjp/css-color(CJSエントリポイントを持たない純粋なESM)を経由するjsdomです。連鎖はjsdom(CJS)→純粋ESMカラーパッケージ→lru-cacheの非同期化されたESMエントリという流れでした。exportsマップはrequireをCJSに、importをESMに正しくルーティングしていましたが、CJSパッケージが純粋ESMパッケージをrequireした際、NodeはESMグラフを解決し、そのグラフ内でlru-cacheのESMエントリポイントがTLAを含むようになっていたため、グラフ全体を同期的にrequire()することが不可能になりました。メンテナーはその後のパッチでトップレベルawaitを差し戻したため、問題は解決されましたが、この障害モードが本番環境で発生することが証明されました。同じERR_REQUIRE_ASYNC_MODULEのカスケードは、Node 22.12.0がこの機能を有効にした際にPrettierとfirebase-toolsでも発生しました。
require(esm)は問題全体を再定義します。デュアルビルドの相互運用上の理由を取り除きますが、TLAからの解放を契約として課します。ESM専用の作者にとって、require()でアクセス可能なグラフのどこかに最初のトップレベルawaitを追加することは、すべてのCommonJSコンシューマーにとって破壊的変更となります。エバート・ポット(Evert Pot)が主張しているように、最初のawaitを追加することで、モジュールをrequire()で取り込んでいたNode.jsユーザーを意図せず壊してしまう可能性があります。つまり、プロジェクトや依存関係における最初のトップレベルawaitは、semverに従うなら新しいメジャーバージョンを構成する可能性があります。semverのメジャーバージョンアップとして扱ってください。
トップレベルawaitはライブラリコードでは実際にはまれです。チョンが実装を最初にテストした際、テストした約30の高影響ESM専用パッケージのいずれもトップレベルawaitを含んでいませんでした。これが同期的なrequire(esm)が実際のパッケージの圧倒的多数をカバーする理由です。
2026年においてCJSビルドはまだ必要か?
ほとんどの新しいパッケージでは不要です。ESM専用をデフォルトとし、特定の制約が強制する場合にのみデュアルビルドを検討してください。3つの質問で分岐します:
- 最小Node.jsターゲットは何か? Node 22.12+であれば(Node 20がEOLとなった今、そうすべきです)、すべてのコンシューマーがESMを
require()できます。ESM専用で提供してください。まだ現場にあるv20.19以前のランタイムを本当にサポートしなければならない場合は、それらのためにCJSビルドが必要です。 require()でアクセス可能なグラフはトップレベルawaitを使用しているか? 自分のコードまたは同期的にロードされる依存関係にある場合、CJSコンシューマーはERR_REQUIRE_ASYNC_MODULEに遭遇します。TLAを削除するか(多くの場合、トップレベルのものではなく遅延import()を使用する)、またはCJSエントリを維持してrequire()ユーザーはサポート対象外であることをドキュメントに記載してください。- コンシューマーをコントロールできるか? 固定された現在のNodeで動作するアプリケーション作者は自由にESM専用にできます。未知のダウンストリームコンシューマーを持つライブラリ作者は、クリーンな
exportsマップを公開し、TLAをバージョニングイベントとして扱うべきです。
これらのいずれも2つ目のフォーマットを強制しない場合、デュアルビルドは余分な重荷です。追加のツール、遅いCI、大きな公開アーティファクト、そして何のメリットもなく再導入されるデュアルパッケージハザードが生じます。
ESM専用への移行:チェックリスト
ESM専用への移行は、主にpackage.jsonの簡素化とモジュール構文の規律化です。手順は以下の通りです:
"type": "module"を設定する。これにより.jsファイルがESMとして解析されます。exportsマップを単一のESMエントリに統合する。デュアルマップが1行になります:
{
"type": "module",
"exports": "./dist/index.js",
"engines": { "node": ">=22.12.0" }
}
推奨されるenginesの値は"^20.19.0 || >=22.12.0"ですが、Node 20がEOLとなった今、>=22.12.0単独で十分です。
- 相対インポートに明示的な
.js拡張子を使用する。ESMでは必須です:import { x } from "./util.js"("./util"ではありません)。 tsconfig.jsonで"moduleResolution": "NodeNext"を設定する。これによりTypeScriptが必須の拡張子を含めてESMを正しくエミットおよび解決します。- CommonJSのグローバル変数を置き換える。ESMには
__dirname、__filename、requireがありません。import.metaから再構築します:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
- 公開前に自分のコードと依存関係全体でトップレベルawaitを監査する。後でTLAを使用する予定がある場合は、パッチとして提供するのではなく、今のうちにメジャーバージョンアップを計画してください。
ライブラリ作者への影響
デュアルCJS/ESMビルドを正当化していた相互運用の壁は、サポートする価値のあるすべてのNode.jsバージョンで解消されています。require(esm)はv25.4.0で安定版となり、22、24、26ラインで利用可能です。残る制約は限定的で明確です。require()の呼び出し元が通過するパスからトップレベルawaitを排除し、最初のトップレベルawaitを破壊的変更として扱ってください。現在のNodeをターゲットとする新しいパッケージについては、ESM専用で提供し、exportsマップを統合し、公開前にグラフのTLAを監査してください。
よくある質問
Node.js 22でESM専用パッケージをrequireできますか?
はい。Node 22は2024年12月3日リリースのv22.12.0からrequire(esm)をデフォルトで有効にしています。そのため、22.12以降のバージョンで動作するCommonJSファイルは、パッケージのグラフにトップレベルawaitが含まれていない限り、ESM専用パッケージを直接require()できます。この機能はその後Node 25.4.0で安定版としてマークされ、24.x LTSラインのv24.15.0にバックポートされましたが、v22.12.0リリース以降Node 22で機能していました。
ERR_REQUIRE_ESMとERR_REQUIRE_ASYNC_MODULEの違いは何ですか?
ERR_REQUIRE_ESMは、CommonJSがESモジュールをrequire()しようとするたびにスローされていた旧来のエラーです。require(esm)が同期的なESMロードを処理するようになったため、サポートされているNodeバージョンではもはや発生しません。ERR_REQUIRE_ASYNC_MODULEは、require()されるESMグラフにトップレベルawaitが含まれている場合にのみスローされる、より限定的な現代のエラーです。require()は非同期評価を待てないためです。そのメッセージは代わりにimport()を使用するよう指示します。最初のエラーはESMがサポートされていないことを意味し、2番目は特定のESM機能が問題であることを意味します。
require(esm)はデフォルトエクスポートを直接返しますか?
いいえ。require(esm)は生の値ではなく完全なモジュール名前空間オブジェクトを返すため、デフォルトエクスポートは戻り値そのものではなく.defaultプロパティに格納されます。これは動的import()の動作と一致します。require()がmodule.exportsを直接返す従来のCommonJSモジュールとは異なります。単一の戻り値が必要な場合、ESモジュールは文字列名'module.exports'を使用してエクスポートすることで、require(esm)が返す内容をカスタマイズできます。CJSラッパーからコンシューマーを移行する際は、常に.defaultを確認してください。
require(esm)が利用可能かどうかを実行時に確認するにはどうすればよいですか?
process.features.require_moduleがtrueかどうかを確認してください。このブール値はNode.jsランタイムによって設定され、ESモジュールのrequireをサポートするすべてのバージョン(Node 20.19以降、22.12以降、および24と26のすべてのライン)でtrueを返します。同じコードベース内で古いランタイムと新しいランタイムの混在をサポートしなければならない場合に、同期的なrequire()と非同期のimport()フォールバックを切り替えるために使用してください。
依存関係がトップレベルawaitを使用している場合、ESM専用での提供は安全ですか?
CommonJSコンシューマーにとっては安全ではありません。require(esm)の制限は自分のファイルだけでなく、require()でアクセス可能なグラフ全体に適用されます。そのため、同期的にロードされる依存関係のどこかにトップレベルawaitがあると、require()を使用している誰もがERR_REQUIRE_ASYNC_MODULEに遭遇します。2026年に記録された事例では、lru-cacheがESMビルドにトップレベルawaitを追加し、メンテナーが差し戻す前にjsdomが推移的に壊れました。ESM専用にする前に完全な依存関係グラフを監査するか、CJSエントリを維持してrequire()ユーザーはサポート対象外であることを明記してください。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k