ESM(ECMAScript Modules)は import と export で書くJavaScript標準のモジュール形式で、CommonJS(CJS)は require() と module.exports で書く、Node.jsで従来から使われてきた形式です。ブラウザやNode.jsがESMを変換なしで実行することを「Native ESM」と呼びます。従来はCommonJSからESMを読む際に非同期の import() が必要でしたが、Node.jsはv22.12.0とv20.19.0からフラグなしで require() によるESMの読み込みに対応し、v25.4.0で正式機能になりました。主要な挙動はNode.js v26.5.0で確認しています。掲載コードは説明用に抜粋し、出力のパスや表示形式は一部省略・整形しています。
まとめ:ESMとCommonJSの違いと2026年の判断基準
- 根本の違いは読み込みの時期です。CJSの
require()は実行時に呼ぶ関数で、ESMのimportは実行前に依存関係を確定させる構文です。ESMの名前付きimportは公開元の変数と結び付いたライブバインディングです。CJSでは公開オブジェクトを共有でき、そのプロパティの更新も読み取れますが、分割代入したプリミティブ値は更新に追従しません。 - Node.jsは
.mjsをESM、.cjsをCJSとして読み、.jsは最寄りのpackage.jsonの"type"で決めます。npm 11のnpm init -yは"type": "commonjs"を書き込むため、そのままではimport文がエラーになります。 - CJSからESMを
require()できるのは、v22.12.0以降・v20.19.0以降・v23.0.0以降です。本体または依存先にトップレベルawaitを含むESMはERR_REQUIRE_ASYNC_MODULEで読めません。 - 新規のアプリとライブラリはESMで書くのが第一候補です。既存のCJSアプリは、ESM専用の依存を読むためだけに移行する必要はなくなりました。
- ライブラリをESMとCJSの2系統で配布すると、同じパッケージが2回読み込まれて状態が分かれます。Node.js 22.12以降を対象とし、CJSから読む依存グラフにトップレベルawaitがなく、利用ツールも対応していれば、ESM単独配布を選べます。
ESMとCommonJSの違い:構文・読み込み・挙動の比較
まず書き方と挙動の差を一覧にします。挙動の行は、いずれもNode.js v26.5.0で確かめた結果です。
| 項目 | ESM | CommonJS |
|---|---|---|
| 読み込み | import { a } from './a.js' |
const { a } = require('./a') |
| 公開 | export const a = 1 |
exports.a = 1 |
| 依存の確定 | 実行前(静的) | 実行時(動的) |
| 書ける場所 | ファイルの最上位のみ | 関数内・条件分岐内も可 |
| 相対パスの拡張子省略 | 不可 | .js等で可(.cjsは必須) |
| トップレベルawait | 可 | 不可 |
| 最上位の this | undefined | module.exports |
| __dirname | ReferenceError | 使える |
| strictモード | 常に有効 | 宣言したときだけ |
| 値の受け渡し | ライブバインディング | 公開オブジェクトを共有可能 |
読み込みの時期とTree Shaking
CJSの require() は普通の関数なので、if の中で呼んだり、変数でパスを組み立てたりできます。ESMの import 宣言はコードを実行する前に解析され、依存関係のグラフが先に組み上がります。実行時に読み込み先を決めたいときは、ESMでもCJSでも使える import() 式を使います。こちらはPromiseを返します。
依存が静的に決まるため、バンドラーは「どのexportがどこからも使われていないか」をコードを動かさずに判定できます。これがTree Shaking(未使用コードの削除)です。CJSは module.exports を実行中に書き換えられるので、同じ精度の判定ができません。Rollupとは?npmでの導入と使い方・rollup.config.mjsの書き方とVite 8以降の位置づけで扱うRollupや、esbuildとは?Go製の高速バンドラの使い方と採用判断を実装目線で解説のesbuildも、ESMの入力を前提に未使用コードを落とします。
ライブバインディングと循環参照の挙動差
同じカウンターをESMとCJSで書き、inc() を1回呼んでから値を読みました。
// counter.mjs
export let count = 0;
export function inc() { count++; }
// counter.cjs
let count = 0;
function inc() { count++; }
module.exports = { count, inc };
// main.mjs
import { count, inc } from './counter.mjs';
import cjs from './counter.cjs';
inc(); cjs.inc();
console.log('ESM count =', count, '/ CJS count =', cjs.count);
// 出力: ESM count = 1 / CJS count = 0
ESMの count は元の変数を指し続けるので1になります。CJSは module.exports を作った時点の0をコピーしているので、関数の中で増やしても外からは0のままです。CJSで最新値を見せたいなら、getter関数を公開します。
循環参照の壊れ方も違います。a と b が互いを読み込む構成で試すと、ESMは ReferenceError: Cannot access 'a' before initialization で止まりました。CJSは止まらず、b.cjs が受け取ったのは途中まで実行された a.cjs の値('A0')でした。同じ b.cjs がイベントループの次の周回で mod.a を読み直すと、完成後の 'A' が取れました。循環参照そのものが必ず失敗するわけではなく、結果は参照する時点と公開方法で変わります。ESMは初期化前の参照を例外で知らせ、CJSは途中の値を黙って返す、という違いです。
ESMとCJSのthis・__dirnameの差
ESMのファイルでは最上位の this が undefined で、__dirname、__filename、require、module、exports はどれも定義されていません。CJSでは最上位の this が module.exports と同じオブジェクトです。this.foo = ... でexportしていたCJSのコードはESMにすると TypeError になり、typeof module !== 'undefined' で環境を判定していたコードは黙ってCJS側の分岐を通らなくなります。移行前に grep で探しておく箇所です。
Node.jsがファイルをESMとCJSのどちらで読むかの判定
Node.jsは次の順でモジュール形式を決めます。
| ファイル | 最寄りのpackage.json | 扱い |
|---|---|---|
| .mjs | 関係なし | ESM |
| .cjs | 関係なし | CJS |
| .js | "type": "module" |
ESM |
| .js | "type": "commonjs" |
CJS |
| .js | typeの指定なし | 構文から自動判定 |
最後の行の自動判定(syntax detection)は、v22.7.0とv20.19.0で既定有効になりました。import 文などESM専用の構文があればESMとして読み直します。ただし毎回次の警告が出て、解析も2回走ります。
$ node e5.js
(node:58834) [MODULE_TYPELESS_PACKAGE_JSON] Warning: Module type of file:///.../e5.js is not specified and it doesn't parse as CommonJS.
Reparsing as ES module because module syntax was detected. This incurs a performance overhead.
To eliminate this warning, add "type": "module" to /.../package.json.
つまずきやすいのは、"type": "commonjs" を明示すると自動判定が止まる点です。npm 11.17.0で npm init -y を実行すると、生成された package.json に "type": "commonjs" が入っていました。この状態で .js に import を書くと、SyntaxError: Cannot use import statement outside a module で止まります。ESMで書くプロジェクトは、最初に "type": "module" へ書き換えてください。package.json の各フィールドの役割はnpmとは?CLIとレジストリの役割・package.jsonとv12の変更点を解説で扱っています。
require(esm):CommonJSからESMを読めるようになった版と条件
以前はCJSからESMを require() すると ERR_REQUIRE_ESM になり、非同期の import() で読むしかありませんでした。このため、依存パッケージがESM専用になるたびに、アプリ側もESMへの移行を迫られていました。Node.jsのドキュメントに残る変更履歴は次のとおりです。
| 変更 | v20系 | v22系 | v23以降 |
|---|---|---|---|
| フラグ付きで追加 | v20.17.0 | v22.0.0 | - |
| フラグなしで既定有効 | v20.19.0 | v22.12.0 | v23.0.0 |
| 実験的機能の警告を廃止 | v20.19.0 | v22.13.0 | v23.5.0 |
| 実験的機能から卒業 | - | - | v25.4.0 |
v20系は2026年4月30日にサポートが終了しています。サポート中のv22(2027年4月30日まで)、v24(2028年4月30日まで)、v26(2026年10月28日にLTS入り予定)はいずれも既定で使えます。手元のNode.jsが対応しているかは process.features.require_module が true かどうかで確かめられます。
// lib.mjs
export let count = 0;
export function inc() { count++; }
export default function hello() { return 'hello'; }
// tla.mjs
await Promise.resolve();
export const x = 1;
// main.cjs
const ns = require('./lib.mjs');
console.log(Object.keys(ns)); // [ '__esModule', 'count', 'default', 'inc' ]
console.log(typeof ns.default); // function
try { require('./tla.mjs'); } catch (e) { console.log(e.code); }
// ERR_REQUIRE_ASYNC_MODULE(tla.mjs はトップレベル await を含む)
console.log(process.features.require_module); // true
戻り値はESMの名前空間オブジェクトです。export default は .default に入るので、const fn = require('./lib.mjs') では関数そのものを取得できません。この例では const fn = require('./lib.mjs').default と書きます。この不便を避けるため、ESM側で export { v as 'module.exports' } と書くと、require() が名前空間ではなくその値を返します(v23.0.0・v22.12.0で追加)。手元でも require('./interop.mjs') が 42 をそのまま返しました。
トップレベル await の制約は、読み込むファイル本体だけでなく依存グラフ全体に及びます。依存先のどこか1つにトップレベル await があれば失敗するので、原因の場所は --experimental-print-required-tla を付けて実行すると表示されます。ライブラリ作者は、エントリーポイントから辿れる範囲にトップレベル await を置かないことが、CJS利用者への配慮になります。
ESMからCommonJSをimportするときの名前付きexportの検出
逆向き、つまりESMからCJSを import するのは以前から可能です。import pkg from './x.cjs' と書けば module.exports 全体が手に入ります。問題は import { foo } from './x.cjs' の名前付きimportで、Node.jsはCJSのコードを実行せずに字句解析して名前を推測します。書き方によっては拾われません。
| CJS側の書き方 | 名前付きで取れた名前 |
|---|---|
exports.foo = 1 |
foo |
module.exports.bar = 2 |
bar |
module.exports = { baz, qux } |
baz、qux |
module.exports = { 'str': baz } |
str |
module.exports = { num: 1 } |
取れない |
module.exports = { qux: function () {} } |
取れない |
module.exports = api(別の場所で組み立て) |
取れない |
オブジェクトリテラルで公開していても、値が識別子でなく数値や関数式だと検出されません。取れないときのエラーは次のとおりで、解決策も表示されます。
SyntaxError: Named export 'dyn' not found. The requested module './c.cjs' is a CommonJS module, which may not support all module.exports as named exports.
CommonJS modules can always be imported via the default export, for example using:
import pkg from './c.cjs';
const { dyn } = pkg;
もう1つの落とし穴は、TypeScriptやBabelがCJSへ変換したライブラリです。exports.__esModule = true と exports.default = ... を持つファイルを import D from で読むと、D は関数ではなく exports オブジェクト全体になり、本来の関数は D.default にあります。バンドラー経由では動いていたコードが、Native ESMで実行した途端に「D is not a function」になる典型的な原因です。
CommonJSからESMへ移行する手順
アプリケーションを移行する場合の作業順です。ライブラリの場合は、後述の配布設計を先に決めます。
package.jsonに"type": "module"を書く。CJSのまま残すファイル(設定ファイルなど)は拡張子を.cjsに変える。require()をimportに、module.exportsをexportに書き換える。相対パスには拡張子を付け、ディレクトリ指定は./sub/index.jsまで書く。__dirname・__filename・JSONの読み込み・require.resolve()を、下のコードの形に置き換える。- テストランナーとTypeScriptの設定を合わせる(後述)。
// __dirname と __filename(v20.11.0で追加、v24.0.0・v22.16.0で安定)
const dir = import.meta.dirname;
const file = import.meta.filename;
// JSON(with { type: 'json' } が必須。v22.12.0で安定)
import config from './config.json' with { type: 'json' };
// require.resolve の代わり(戻り値は file:// のURL文字列)
const url = import.meta.resolve('some-package');
// どうしても require が要る箇所
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacy = require('./legacy.cjs');
import.meta.resolve() と require.resolve() は同じ答えを返すとは限りません。exports に import と require の条件を持つパッケージで試すと、前者は dist/index.mjs、後者は dist/index.cjs を返しました。読み込み方に応じた解決をするためで、ファイルパスを組み立てる処理を置き換えるときは返り値を確認してください。
import() はCJSからも呼べるので、全ファイルを一度に変えずに、末端のモジュールからESMへ寄せていく進め方も取れます。v22.12以降ならCJS側から require() でESM化したファイルを読めるため、移行の途中でも動かし続けられます。
移行時に出るエラーと原因の対応表
Node.js v26.5.0で実際に出たエラーを、原因と直し方の順にまとめます。
| エラー | 原因 | 直し方 |
|---|---|---|
| Cannot use import statement outside a module | CJS扱いの.jsにimport | "type": "module" か .mjs |
| ERR_MODULE_NOT_FOUND | 相対パスの拡張子省略 | ./util.js と書く |
| ERR_UNSUPPORTED_DIR_IMPORT | ディレクトリをimport | ./sub/index.js と書く |
| ERR_IMPORT_ATTRIBUTE_MISSING | JSONを属性なしでimport | with { type: 'json' } |
| ReferenceError: __dirname is not defined | ESMにCJSの変数 | import.meta.dirname |
Named export 'x' not found |
CJSの名前を検出できない | default importで受ける |
| ERR_REQUIRE_ASYNC_MODULE | TLAを含むESMをrequire | import() で読む |
| ERR_REQUIRE_ESM | require(esm)非対応・無効化 | Node.jsを更新 |
Cannot access 'a' before initialization |
ESMの循環参照 | 依存の向きを整理 |
拡張子とディレクトリのエラーには、Node.jsが Did you mean to import "./util.js"? のように正しい指定を添えてくれます。ERR_REQUIRE_ESM が出る場合は、Node.jsの対応版に加え、require(esm)を無効化する起動オプションやテストランナー側の対応も確認してください。CIのNode.jsだけ古い、という食い違いも疑ってください。
ライブラリ配布:exports条件とデュアルパッケージの落とし穴
ESMとCJSの両方を配る従来の定番は、exports に import と require の条件を書き分ける方法です。
{
"name": "dual",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}
この形には、同じプロセスで両方の経路から読まれるとファイルが2つ読み込まれる問題があります。モジュール内に状態を持つパッケージで確かめました。
// node_modules/dual/dist/index.mjs
let n = 0;
export const next = () => ++n;
// node_modules/dual/dist/index.cjs
let n = 0;
exports.next = () => ++n;
// app.js("type": "module")
import { createRequire } from 'node:module';
import { next } from 'dual';
const require = createRequire(import.meta.url);
const cjs = require('dual');
console.log(next(), next(), cjs.next(), next === cjs.next);
// 出力: 1 2 1 false
ESM側で2回数えたのに、CJS側のカウンターは1から始まりました。シングルトンの接続プール、プラグインの登録表、instanceof による型判定が、読み込み経路ごとに別物になります。アプリ本体がESMでも、依存の1つがCJSで同じパッケージを require() していれば起きるため、利用者側では気づきにくい不具合です。
require(esm)が使える今は、ESMの1ファイルだけを配る選択肢が現実的です。Node.jsの "module-sync" 条件を使うと、import からも require() からも同じESMファイルに解決されます。
{
"exports": {
".": {
"module-sync": "./s.mjs",
"default": "./s.cjs"
}
}
}
// import 経由: module-sync(ESM) / require 経由: module-sync(ESM)
"default" は、module-sync 条件を知らない古いNode.jsとツール向けの退避先です。engines を "node": ">=22.12" にできるなら、"type": "module" と "exports": "./index.js" だけのESM単独配布で十分です。
TypeScriptとJestの設定:module・moduleResolutionとESMテスト
TypeScript 6.0・7.0のmoduleとmoduleResolution
TypeScript 6.0で module の既定値が esnext に変わり、moduleResolution: "node"(node10)は非推奨になりました。TypeScript 7.0.2の tsc で古い設定を通すと、次のエラーで止まります。
error TS5108: Option 'moduleResolution=node10' has been removed. Please remove it from your configuration.
error TS5108: Option 'esModuleInterop=false' has been removed. Please remove it from your configuration.
| 実行環境 | module | moduleResolution | 拡張子 |
|---|---|---|---|
| Node.jsで直接実行 | nodenext | (nodenextに連動) | 必須 |
| Vite・esbuildなどのバンドラー | esnext | bundler | 省略可 |
| CJSのまま残すNode.jsアプリ | nodenext+type: commonjs | (nodenextに連動) | 省略可 |
nodenext では、.ts ファイル同士の相互参照でも import { u } from './util.js' と出力後の拡張子 .js を書きます。省くと TS2835: Relative import paths need explicit file extensions になります。CJSのまま残すプロジェクトは、package.json を "type": "commonjs" にして module: "nodenext" を使えば、拡張子なしの import { u } from "./util" から require("./util") が出力されました。module: "commonjs" も7.0.2ではエラーにならず、moduleResolution を書かなければbundlerとして解決します。6.0の変更全体はTypeScript 6.0とは|5から6への移行手順とts5to6・7.0との違い【6.0.3対応】、export と import の書き分けはTypeScriptのモジュールとクラスの違い|export/importの書き分けと7.0の変更点にまとめています。
Jest 30.5のESM対応とVitest
Jest公式はESM対応を今も「experimental」と書いています。"type": "module" のプロジェクトでJest 30.5.2をNode.js v26.5.0上で実行すると、ESMのテストファイルは「Must use import to load ES Module」で失敗しました。CJSのテストから .mjs を require() した場合も同じです。エラーメッセージには「Node v24.9以降ならrequire(esm)をネイティブに扱う」と案内が出ますが、手元の組み合わせではフラグなしでは通りませんでした。
NODE_OPTIONS=--experimental-vm-modules npx jest
# Tests: 1 passed, 1 total(ExperimentalWarning: VM Modules is an experimental feature)
--experimental-vm-modules を付けると通ります。もう1つの制約は、jest.mock() の巻き上げがESMの import には効かないことです。ESMのモジュールを差し替えるには jest.unstable_mockModule() と動的 import() の組み合わせになります。テストを大量に抱えるプロジェクトでは、ESM移行と同時にVitest(2026年10月時点の最新は5.0.3)へ切り替える判断も現実的です。Vitestはブラウザ実行まで含めてVitest Browser Modeの使い方|Playwrightでの導入・headless設定とjsdomとの使い分け【Vitest 5対応】で扱っています。
ブラウザのNative ESM:type=moduleとimport maps
ブラウザでは <script type="module"> でESMを直接読めます。MDNのbrowser-compat-data 8.1.5による対応開始版は次のとおりです。
| 機能 | Chrome | Edge | Firefox | Safari |
|---|---|---|---|---|
| script type=module | 61 | 16(Chromium版は79) | 60 | 10.1 |
| 動的 import() | 63 | 79 | 67 | 11.1 |
| import maps | 89 | 89 | 108 | 16.4 |
| import attributes | 123 | 123 | 138 | 17.2 |
import mapsを使うと、import React from "react" のようなパッケージ名だけの指定を、ブラウザ上でURLに対応づけられます。npmのパッケージをESMとして配るCDNの代表がesm.shです。
<script type="importmap">
{ "imports": { "react": "https://esm.sh/[email protected]" } }
</script>
<script type="module">
import React from "react";
</script>
Reactのnpmパッケージ(19.2.4)は index.js がCJSで、"type" も持ちません。それでもesm.shに問い合わせると、応答ヘッダーは x-esm-path: /[email protected]/es2022/react.mjs で、ESMに変換したファイルが返ってきました。ビルドなしで試作するには手軽ですが、本番では外部CDNの可用性に依存します。リクエスト数は、依存関係のバンドル方法やキャッシュ状態によって変わります。
開発時だけNative ESMで配り、本番はバンドルする構成の代表がViteです。Viteの開発サーバーはソースをESMのまま返すため、起動時に全体をバンドルしません。本番ビルドの仕組みとVite 8での変更はViteとは?Reactの環境構築手順とVite 8の変更点・create-react-appからの移行を参照してください。
ESMへ移行すべきでない場面と判断基準
require(esm)の登場で、「ESM専用の依存を使いたいから移行する」という理由はほぼなくなりました。既存アプリの移行は、得られるものが具体的にあるときだけ着手すれば十分です。次の条件に当てはまるプロジェクトは、CJSのまま運用を続けるほうが安全です。
- Jestのテストが多く、
jest.mock()の巻き上げに依存している。ESMの依存をモックするテストでは、モック登録と読み込み順の書き換えが必要になります。置き換え先のjest.unstable_mockModule()は実験的APIです。 require.cacheの削除による再読み込みや、条件付きのrequire()で任意の依存を読む設計を使っている。ESMには読み込み済みモジュールを捨てる標準の手段がありません。- 本番環境でrequire(esm)が使えず、Node.jsの更新や非同期読み込みへの変更もすぐにはできない。この場合はアプリをESMにしても、CJSのままの依存からESMを読めない問題が残ります。
逆に、新しく作るアプリとライブラリはESMで書くべきです。Node.js、ブラウザ、Deno、Bunのすべてが標準でESMを解釈し、TypeScript 6.0以降の既定値もESMです。迷ったときの基準は「新規はESM、既存はNode.jsを22.12以降に上げることを先に済ませ、移行は依存の更新が止まったときに考える」です。
よくある質問
Native ESMとは何ですか?
ブラウザやNode.jsが、バンドラーやトランスパイラーで変換せずにESM(import・export)をそのまま実行する方式です。ブラウザは <script type="module">、Node.jsは .mjs か "type": "module" で有効になります。
ESMとCommonJSはどちらを使うべきですか?
新規のコードはESMを選んでください。JavaScriptの標準仕様で、ブラウザとNode.jsの両方で同じ書き方が通ります。既存のCommonJSアプリは、Node.js v22.12以降ならESM専用の依存も require() で読めるため、急いで移行する必要はありません。
requireでESMを読むとERR_REQUIRE_ESMになるのはなぜですか?
主な原因は、require(esm)の既定対応版より古いNode.jsを使っていることです。ただし、対応版でも起動オプションで無効化している場合などは発生します。それ以降はフラグなしでESMを require() できます。新しい版で出るのは、トップレベル await を含むESMを読んだときの ERR_REQUIRE_ASYNC_MODULE で、こちらは import() に書き換えます。
esm.shとは何ですか?
npmやJSRのパッケージを、インストールやビルドなしでESMとして import できるようにするCDNです。https://esm.sh/パッケージ名@バージョン のURLで読み込み、CJSで配布されているパッケージもESMに変換して返します。ソースはMITライセンスで公開されています。
.mjsと.cjsはどう使い分けますか?
package.json の "type" にかかわらず形式を明示したいファイルに付けます。"type": "module" のプロジェクトでCJSの設定ファイルを残すなら .cjs、CJSのプロジェクトで一部だけESMにするなら .mjs です。拡張子は "type" より優先されます。