ESM のプロジェクトで、ディレクトリを指す相対 import を実行したら、次のエラーで落ちることがあります。
Error [ERR_UNSUPPORTED_DIR_IMPORT]: Directory import '/app/greet' is not supported resolving ES modules imported from /app/index.js
Did you mean to import "./greet/index.js"?
原因は、ES Module(ESM)では「ディレクトリを import して中の index.js を自動で探す」ことができないのに、import { x } from './greet' のようにディレクトリを指していることです。2行目のとおり、Node 自身が Did you mean to import "./greet/index.js"? と、書くべきパスまで教えてくれます。CommonJS の頃は require('./greet') が ./greet/index.js を自動で探してくれたので、type: "module" を入れた(または .mjs にした)タイミングで、それまで動いていた import が落ちます。
なぜ「ディレクトリ指定」が通らないのか(CJS と ESM の解決規則の差)
同じ ./greet でも、そのファイルが CommonJS 扱いか ESM 扱いかで、Node のモジュール解決の仕方が変わります。
| 書き方 | CommonJS(require) | ESM(import) |
|---|---|---|
'./greet'(ディレクトリ) | ./greet/index.js を自動で探す | 探さない → ERR_UNSUPPORTED_DIR_IMPORT |
'./util'(拡張子なしのファイル名) | ./util.js を自動で補う | 補わない → ERR_MODULE_NOT_FOUND(別記事) |
'./greet/index.js'(ファイルまで) | 通る | 通る |
CommonJS は「拡張子の補完」「ディレクトリの index.js 探索」を解決の一部として行っていました。ESM はこれをやりません。インポート指定子は URL のように厳密に扱われ、書いたとおりのパスだけを探します。だから手元では CJS で通っていたディレクトリ import が、type: "module" にした環境や .mjs にしたファイルでだけ落ちる、という形になります。
再現(最小構成)
package.json を ESM 指定にします。
{
"type": "module"
}
greet/index.js を置き、ディレクトリ名で import します。
// greet/index.js
export const greeting = 'hello';
// index.js(type:module 下なので ESM 扱い)
import { greeting } from './greet';
console.log(greeting);
node index.js を実行すると ERR_UNSUPPORTED_DIR_IMPORT が出て、終了コードは 1 になります。greet/index.js は存在するのに、ディレクトリ ./greet から index.js を自動で見つけないためです。
解決:index.js までパスを書く
ESM では、ディレクトリではなくファイルまで指定します。
// index.js(ESM のまま。ファイルまで書いた)
import { greeting } from './greet/index.js';
console.log(greeting);
これで終了コード 0 で動きます。エラーの2行目のヒント(Did you mean to import "./greet/index.js"?)がそのまま答えです。このヒントは実行時のカレントディレクトリ(cwd)を基準に候補を探すので、ファイルのある場所以外から実行する(たとえば親ディレクトリから node reproduce/index.js)とヒント自体が出ないことがあります。表示の形も版で変わり、新しい版は "./greet/index.js" と引用符付き、古い Node 18 は引用符が無く候補パスの基準もずれます。ヒントの有無や形にかかわらず、対処は「ディレクトリではなくファイルまで書く」で同じです。
なお、greet/ の中に package.json を置いて exports や main を書いても、相対パスのディレクトリ import(./greet)はそれを見ません。exports/main が効くのは node_modules 経由などでパッケージ名として import するときだけです。ローカルの相対 import では、./greet/index.js とファイルまで書きます。
仕組み:ESM の指定子は「URL」
相対パスやローカルファイルの import は、ESM では最終的に file: URL として解決されます。URL に「フォルダの既定ファイルを推測する」機能が無いのと同じで、ESM も no folder mains(ディレクトリの index.js を自動では探さない)・no default extensions(拡張子を自動で補わない)という規則で、書かれたパスだけを見ます。URL がフォルダを指しても既定ファイルを勝手に選ばないのと同じ発想で、読み込むファイルを一意に決めるための規則です。
切り分け(うまくいかないとき)
- ディレクトリではなく拡張子なしのファイルで出た:
import x from './util'(./util.jsが存在)のようにファイルの拡張子省略なら、エラーコードはERR_MODULE_NOT_FOUNDになります。対処は ERR_MODULE_NOT_FOUND(相対 import の拡張子) を参照してください。 - npm パッケージの import で出た:相対 import ではなくパッケージ名(
import x from 'pkg')で似た症状が出る場合は、そのパッケージ側のexports/mainの設定の問題です。原因が別なので、この記事の「ファイルまで書く」対処では直りません。 - 同じ ESM 化で別のエラーも出ている:
type: "module"にした途端に、require is not defined(require is not defined in ES module scope)や__dirname is not defined(__dirname is not defined in ES module scope)も一緒に出ることがあります。どれも原因は同じ(ESM 扱い)です。 - そもそも ESM にしたくなかった:
package.jsonの"type": "module"を外すか、そのファイルを.cjsにすれば CommonJS 扱いに戻り、ディレクトリ import が復活します。ESM 扱いになる条件は require is not defined in ES module scope の表も参照してください。