Featured image of post Node.jsでESMをrequire可能にする仕様の安定化とエコシステムの変化 Featured image of post Node.jsでESMをrequire可能にする仕様の安定化とエコシステムの変化

Node.jsでESMをrequire可能にする仕様の安定化とエコシステムの変化

Node.js v22/23以降でESMをrequire可能になった仕様の安定化とエコシステムへの影響を解説。CommonJSプロジェクトからESモジュールを同期的に読み込めるようになった背景、実装方法、既存コードベース移行への影響を詳しく紹介します。

Node.jsでESMをrequire可能にする仕様の安定化とエコシステムの変化

長年にわたり、Node.js開発者はCommonJS(CJS)とES Modules(ESM)の間にある厄介な分断に直面してきました。CJSコードはimport()でESMモジュールを非同期に読み込めましたが、CJSの心臓部である同期的なrequire()はESMをまったく読み込めませんでした。そのため、コードベースの移行は全か無かの対応を強いられていました。

Node.js 22および23で--experimental-require-moduleフラグが安定化され、状況が変わりました。Node 23以降、ESモジュールのグラフがトップレベルawaitを使用していない限り、同期的なrequire()でESモジュールを読み込めるようになりました。


問題:CJSとESMの相互運用性

根本的な問題は、CommonJSが同期的にモジュールを読み込む(require())のに対し、ES Modulesは非同期的にパース・実行される(トップレベルawait、Promiseを返す静的インポート文)ことです。ESMグラフは循環参照やライブバインディングを含むことがあり、CJSでは処理できませんでした。

Node 22以前にCJSからESMを読み込む唯一の方法は以下の通りです。

// CJSファイル
async function loadESM() {
  const esmModule = await import('./esm-module.mjs');
  return esmModule.default;
}

これにより、CJSのコードベースはすべてをESMに書き換えるか、厄介な非同期ラッパー層を維持するかを強いられていました。


解決策:Node 22+ での require(esm)

Node 22で導入された--experimental-require-moduleフラグにより、ESモジュールとその依存関係グラフ全体にトップレベルawaitがなければ、require()でESMモジュールを同期的に読み込めるようになりました。

Node 23ではこの機能が--require-moduleフラグ(experimentalなし)の背後で安定化され、Node 24ではデフォルトで有効化される予定です。

// CJSファイル — Node 22+ でフラグ付きで動作
const lodashEs = require('lodash-es'); // ESMパッケージ!
const result = lodashEs.merge({ a: 1 }, { b: 2 });
console.log(result); // { a: 1, b: 2 }

内部動作

require()がESMファイルに遭遇すると、以下の解決が行われます。

  1. NodeはモジュールをESMとして識別(package.json"type": "module"または.mjs拡張子)
  2. ESMグラフにトップレベルawaitが含まれているかチェック — 含まれている場合はERR_REQUIRE_ASYNC_MODULEエラーをスロー
  3. トップレベルawaitがない場合、ESMグラフを同期的にパース・評価
  4. デフォルトエクスポートが直接返される。名前付きエクスポートは返されたオブジェクトのプロパティからアクセス可能
// ESMモジュール: esm-lib.mjs
export const greet = (name) => `Hello, ${name}!`;
export default class Logger {
  log(msg) { console.log(msg); }
}

// CJSコンシューマー
const lib = require('./esm-lib.mjs');
lib.greet('World');        // "Hello, World!"
new lib.default().log('hi'); // "hi"

名前付きエクスポートは直接アクセスでき、デフォルトエクスポートは.defaultでアクセスします。


サポートされるESM機能

機能require(esm)でのサポート備考
名前付きエクスポート直接プロパティアクセス
デフォルトエクスポート単一エクスポートの場合は直接、または.defaultで返却
ライブバインディング不可値のスナップショットを取得、ライブバインディングはなし
循環依存部分的単純な循環は可、複雑なグラフは失敗する可能性あり
トップレベルawait不可ERR_REQUIRE_ASYNC_MODULEをスロー
動的import()require経由で読み込まれたESM内部でも動作

制限とエラーハンドリング

最も重要な制限はトップレベルawaitです。トップレベルでawaitを使用するESMモジュールはrequire()で読み込めません。

// top-level-await.mjs
const data = await fetch('https://api.example.com/data').then(r => r.json());
export default data;

// CJSコンシューマー
const data = require('./top-level-await.mjs');
// ERR_REQUIRE_ASYNC_MODULE: require() はトップレベルawaitを持つ
// ESモジュールには使用できません

対処方法:

  • ESMモジュールからトップレベルawaitを削除する
  • require()の代わりにawait import()を使用する
  • 非同期ラッパーで包む(ただしモジュール自体の問題のため上記の例では効果なし)

正しいアプローチ:

const data = await import('./top-level-await.mjs');

CJSコードベースの移行メリット

この機能により、CJSからESMへの段階的移行が劇的に簡素化されます。

以前(Node 20):CJSコードベースではESM専用パッケージをawait import()ラッパーなしでは使用できず、CJSモジュールのトップレベルで使用することが不可能でした。

// 以前の苦痛 — 非同期ラッパーが必要
const getExpress = () => import('express');

以後(Node 23+):ほとんどのESMパッケージを直接require()できるようになり、段階的な移行が可能に。

// CJSファイル、Node 23+
const express = require('express'); // expressがESMを出荷していれば動作
const { nanoid } = require('nanoid'); // ESM専用パッケージ!

CJSコードベースはモジュールシステムを書き換えることなく、ESM専用の依存関係を採用できるようになりました。nanoidchalk(v5+)、p-limitなどのESM専用ライブラリがCJSからアクセス可能になります。


エコシステムへの影響

require(esm)の安定化はエコシステムに大きな影響を与えます。

  • パッケージメンテナーはCJS/ESMのデュアルビルドを廃止し、ESM専用で出荷できるようになる(CJSコンシューマーがrequire()できるため)
  • CJSコードベースは「ビッグバン」な書き換えなしにESM依存関係を段階的に導入可能
  • ツール(Jest、ESLint、TypeScript)のモジュール解決が簡素化

ただし、パッケージメンテナーはエクスポート内のトップレベルawaitがrequire()互換性を壊すことを認識しておく必要があります。


まとめ

Node.js 22/23でのrequire(esm)の安定化は、CommonJSとES Modulesの間の長年にわたるギャップを埋めるものです。CJSコードベースはESM専用パッケージを直接利用できるようになり、段階的な移行を妨げていた非同期ラッパーの負担がなくなりました。

大規模なCJSアプリケーションを維持しているチームにとって、これはゲームチェンジャーです。今日からモダンなESM依存関係を採用し、コードベースをモジュールごとに移行し、require()の同期的なシンプルさを犠牲にすることなくESMエコシステムのフルアドバンテージを活用できます。