エラーの内容
Error [ERR_UNSUPPORTED_DIR_IMPORT]: /app/src/utils is not supported resolving ES modules imported from /app/src/index.mjs. Did you mean to import /app/src/utils/index.js?
移行時によくある痛みです。CommonJS では完璧に動いていたものが、ES Modules に切り替えた途端に壊れる。あるいは "type": "module" を package.json に追加して保存した瞬間、大量のインポートが一気に壊れる、というパターンです。
なぜこのエラーが起きるのか
CommonJS は寛容でした。require('./utils') と書けば、Node.js は暗黙的に ./utils/index.js として解決してくれました。しかし ESM ではその挙動はなくなりました — 仕様上、モジュール指定子は完全に明示する必要があります。Node.js はファイルを推測してくれません。
つまり、このインポート:
import { formatDate } from './utils';
…は ERR_UNSUPPORTED_DIR_IMPORT をスローします。./utils はディレクトリだからです。Node.js はディレクトリを検出するとエントリーポイントの選択を拒否し、そこで止まってしまいます。
段階的な修正方法
方法1:明示的なファイルパスを指定する(最も手早い修正)
実際のファイルを直接指定します:
// 修正前(ESM では壊れる)
import { formatDate } from './utils';
// 修正後(正しい書き方)
import { formatDate } from './utils/index.js';
.js 拡張子は必須です — TypeScript のコンパイル出力であっても同様です。ESM は拡張子を一切推測しません。
方法2:package.json の exports フィールドを使う(クリーンでスケーラブル)
utils が明確なモジュール境界を持つ場合、独自の package.json を exports マップ付きで作成します:
// utils/package.json
{
"name": "utils",
"type": "module",
"exports": {
".": "./index.js"
}
}
これで元の短いインポートが再び機能するようになります:
import { formatDate } from './utils';
Node.js は utils/package.json を読み込み、exports マップを見つけて utils/index.js に解決します。魔法ではなく、明示的な設定があるだけです。
方法3:バレル再エクスポートファイルを作成する
utils/ 以下に10個以上のファイルがある場合は、すべてを一箇所から再エクスポートする index.js を作成します:
// utils/index.js
export { formatDate } from './formatDate.js';
export { slugify } from './slugify.js';
export { parseEnv } from './parseEnv.js';
そしてそこから明示的にインポートします:
import { formatDate, slugify } from './utils/index.js';
クリーンなパブリック API となり、利用側ごとに1つのインポート文で済みます。
方法4:TypeScript ユーザー向け — moduleResolution を node16 または bundler に設定する
TypeScript を ESM にコンパイルする場合、tsconfig.json に ESM のルールに合った解決モードを設定する必要があります:
// tsconfig.json
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "node16",
"target": "ES2022"
}
}
node16 または bundler を使用すると、TypeScript はコンパイル時にディレクトリインポートを検出します。node が実行する前にエラーを捕捉できるため、CI での実行時クラッシュよりもずっと良い方法です。
壊れたインポートを素早く一括検出する
ファイルを一つずつ修正しないでください。まずプロジェクト全体に grep を実行しましょう:
# 拡張子のないディレクトリパスのような相対インポートを検出
grep -rn "from '\./[^']*[^.][^a-z]'" src/ --include="*.mjs" --include="*.js"
# より広いパターン — シングルクォートとダブルクォートの両方を対象
grep -rEn "from ['\"]\./[^'\"]+[^/]['\"]" src/
ファイル名と行番号付きの一覧が得られます。一つずつエラーを追いかけるのではなく、一度にまとめて修正できます。
修正の確認
エントリーポイントを直接実行します:
node src/index.mjs
エラーが消えましたか?よし。プロジェクト全体の確認には、テストスイートを実行します:
node --experimental-vm-modules node_modules/.bin/jest
# または
npm test
アプリのロジックを実行せずにモジュール解決を単独で検証するには:
node --input-type=module <<< "import './src/utils/index.js'; console.log('OK');"
OK と表示されれば、解決は正常です。
今後このエラーを避けるためのヒント
- ESM のインポートでは常に
.js拡張子を記述する —.tsソースファイルであっても同様です。TypeScript は.jsにコンパイルするので、Node.js が実行時に見るのはそちらです。 - ESLint で強制する:
eslint-plugin-importに"import/extensions": ["error", "always"]を設定すると、コードレビュー前に拡張子の欠落を検出できます。 - 大規模な CJS コードベースの移行時は? まずコードモドを実行しましょう —
cjs-to-esm-converterまたは@babel/plugin-transform-modules-commonjsが、気づかずに依存していたすべての暗黙的な解決を洗い出してくれます。 - **モノレポでは、**すべてのサブパッケージの
package.jsonにexportsフィールドを追加してください。最初は手間がかかりますが、各パッケージにクリーンで明示的な API 境界を与えることになり、コンシューマーが2つ以上になった瞬間にその恩恵を実感できます。

