⚠️ この記事はアフィリエイト広告(プロモーション)を含みます。リンク先で発生した収益の一部が運営者に支払われますが、読者の購入価格には一切影響ありません。
結論から言うと、package.jsonに"type": "module"を足した瞬間に出るERR_REQUIRE_ESMとCannot use import statement outside a moduleは、ファイルごとに拡張子を.mjs/.cjsで固定するか、require(esm)(Node 22で安定)に逃がすかの2択でほぼ片付きます。この記事を読み終えると、ESMとCJSが混ざったリポジトリでもnode index.jsがエラーなしで起動し、Jestとtsupのビルドまで一発で通る状態を自分の手で作れるようになります。
検証環境は Node.js v22.11.0 / npm 10.9.0 / Windows 11 + WSL2 Ubuntu 24.04 です。実際に私が社内ツール(約120ファイル)をESM移行したときの所要時間と失敗ログも置いておきます。
1. なぜ"type: module"でERR_REQUIRE_ESMが起きるのか(拡張子解決の優先順位)
Node.jsは「拡張子」と「一番近いpackage.jsonのtypeフィールド」の2つで、そのファイルをESMとして読むかCJSとして読むかを決めます。優先順位は固定で、覚えるべきは次の3行だけです。
-
.mjs→ 常にESM(typeを無視) -
.cjs→ 常にCJS(typeを無視) -
.js→ 直近のpackage.jsonのtypeに従う(未指定ならcommonjs)
つまり"type": "module"を入れると、それまで動いていた.jsのrequire()が全部ESM扱いになり、require is not defined in ES module scopeで落ちます。私の移行作業では初日に41ファイルがこのエラーで停止しました。原因の8割が「拡張子.jsのままmodule.exportsを使っていた設定ファイル」です。
まず現状把握。CJS構文が残っているファイルを一覧化します。これは実際に動くスクリプトです。
# require/module.exports を使っている .js を列挙(Node 22, ripgrep使用)
rg -l --glob '*.js' -e 'module\.exports' -e 'require\(' src \
| sort > cjs-files.txt
wc -l cjs-files.txt # 私の例では 41 と表示された
ripgrepが無ければgrep -rl 'module.exports' srcでも代用できます。この41個を「.cjsにリネーム」か「ESM構文に書き換え」のどちらかに振り分けるのが移行の本体です。
2. 設定ファイルだけ.cjsに逃がす(eslintrc・jest.config問題)
一番ハマるのが.eslintrc.jsやjest.config.jsなどツール側がrequireで読み込む設定ファイルです。アプリ本体はESMにしたいが、ツールがCJSを期待する——この衝突は設定ファイルだけ.cjsにするのが2026年時点で最も摩擦が少ない解です。
動く最小構成がこちらです。jest.config.cjsにしてmodule.exportsのまま残します。
// jest.config.cjs (拡張子を .cjs にすればtype:moduleでもCJSとして読まれる)
/** @type {import('jest').Config} */
module.exports = {
testEnvironment: 'node',
// ESMソースをBabelなしで動かすなら NODE_OPTIONS=--experimental-vm-modules
transform: {},
moduleNameMapper: {
// ESMでは拡張子必須。.js付きimportをそのまま解決させる
'^(\\.{1,2}/.*)\\.js$': '$1',
},
};
起動はこう。Windows PowerShellとbashで書き分けが必要なのが地味な落とし穴です。
# bash / WSL
NODE_OPTIONS=--experimental-vm-modules npx jest --config jest.config.cjs
# Windows PowerShell (NODE_OPTIONSの渡し方が違う)
$env:NODE_OPTIONS="--experimental-vm-modules"; npx jest --config jest.config.cjs
私はここで30分溶かしました。PowerShellでNODE_OPTIONS=... npxと前置きするbash記法を書いてコマンドが見つかりませんになり続けたのが原因です。.cjs化で41ファイルのうち設定系9ファイルを即解決でき、残り32ファイルがアプリ本体の書き換え対象になりました。
3. Node 22の"require(ESM)"でモジュールの壁を越える
2026年の最大のニュースは、Node.js 22(v22.12.0で--experimental-require-moduleがデフォルトON、v23以降は完全安定)でCJSから同期require()でESMを読めるようになったことです。これまでimport()の動的ロード(=async)に書き換えるしかなかった箇所が、同期のまま済みます。
条件は「読み込まれるESM側にトップレベルawaitが無いこと」だけ。これさえ守れば次が動きます。
// logger.mjs (ESM・トップレベルawaitなし)
export function log(msg) {
console.log(`[${new Date().toISOString()}] ${msg}`);
}
export const VERSION = '1.0.0';
// main.cjs (CJSからESMを同期requireする — Node 22で動く)
const { log, VERSION } = require('./logger.mjs');
log(`起動しました version=${VERSION}`); // [2026-06-03T...] 起動しました version=1.0.0
node main.cjsで警告なしに実行できます(v22.11ではExperimentalWarningが出るので--no-warningsを付けるか22.12以降に上げる)。ただしESM側にトップレベルawaitがあるとERR_REQUIRE_ASYNC_MODULEに変わるので、その場合は次章の動的importに戻します。私の本体32ファイルのうち、27ファイルはこの同期requireで書き換え不要になり、移行の見積もりが2日から半日に縮みました。
4. __dirnameが消える問題をimport.meta.urlで埋める
ESM化で必ず踏むのが「__dirnameと__filenameがESMに存在しない」問題です。パス解決をしている全ファイルが__dirname is not definedで落ちます。import.meta.urlから再構築するのが定石で、丸暗記用に貼っておきます。
// path-helper.mjs (ESMで __dirname / __filename を復活させる)
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { readFileSync } from 'node:fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 例: 同階層の config.json を読む
const cfg = JSON.parse(readFileSync(join(__dirname, 'config.json'), 'utf-8'));
console.log(cfg);
Node 20.11 / 21.2 以降ならimport.meta.dirnameとimport.meta.filenameが直接使えるので、上の3行はconst __dirname = import.meta.dirname;の1行で済みます。私は社内ツールが Node 18 を混在させていたためfileURLToPath版で統一しました。__dirname関連の落とし穴は本体32ファイル中14ファイルに潜んでいて、これが2番目に多いエラー要因でした。
5. package.jsonのexportsでdual package(ESM+CJS両対応)を配る
ライブラリとして配布する場合、利用者がESMでもCJSでも使えるようdual packageにします。tsup(v8系)を使うと1コマンドで両形式を吐けます。exportsフィールドの書き方を1文字間違えるとERR_PACKAGE_PATH_NOT_EXPORTEDで利用者側が詰むので、動く完成形を置きます。
{
"name": "my-lib",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts"
}
}
exportsの中でtypesを一番上に書くのが必須ルールです(TypeScriptは上から順にマッチするため、import/requireより下に置くと型が解決されない)。npm run buildでdist/index.js(ESM)・dist/index.cjs(CJS)・index.d.tsが同時生成され、import派にもrequire派にも配れます。
まとめ:迷ったら拡張子で殴れ
移行で得た教訓を数値で残します。全120ファイル中、エラーで停止したのは41ファイル、内訳は設定系9・__dirname系14・require構文27(重複あり)。解決の優先順位はこうでした。
-
設定ファイルは
.cjsに逃がす(9ファイルを5分で解決・最もコスパ高) -
本体はNode 22の
require(ESM)で同期のまま(27ファイルが書き換え不要に) -
__dirnameはimport.meta.dirnameへ置換(Node 20.11+なら1行) - 配布物は
exportsでtypesを先頭にしたdual package
「ESMかCJSか」で悩んだら、まず.mjs/.cjsで拡張子を固定してtypeの影響を切り離すのが最速です。同じ罠で時間を溶かす人が1人でも減れば幸いです。