Node で .js を実行したら、次のエラーで落ちることがあります。
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/app/util' imported from /app/index.js
Did you mean to import "./util.js"?
ファイルは確かにそこにあるのに「見つからない」と言われます。原因は、ES Module(ESM)の相対 import では拡張子を省略できないのに、import { x } from './util' のように拡張子なしで書いていることです。2行目のとおり、Node 自身が Did you mean to import "./util.js"? と、足すべき拡張子まで教えてくれます。CommonJS の頃は require('./util') が ./util.js を自動で補ってくれたので、type: "module" を入れた(または .mjs にした)タイミングで、それまで動いていた import が一斉に落ちます(import を書いた種別未確定の .js が、新しい Node の自動検出で ESM 扱いになって落ちることもあります。詳細は後述の切り分けと require is not defined in ES module scope の表を参照)。
なぜ「あるのに見つからない」のか(CJS と ESM の解決規則の差)
同じ ./util でも、そのファイルが CommonJS 扱いか ESM 扱いかで、Node のモジュール解決の仕方が変わります。
| 書き方 | CommonJS(require) | ESM(import) |
|---|---|---|
'./util'(拡張子なし) | ./util.js を自動で補う | 補わない → ERR_MODULE_NOT_FOUND |
'./dir'(ディレクトリ) | ./dir/index.js を自動で探す | 探さない → 別のエラー(ERR_UNSUPPORTED_DIR_IMPORT) |
'./util.js'(拡張子あり) | 通る | 通る |
CommonJS は「拡張子の補完」「index.js の探索」を解決の一部として行っていました。ESM はこれをやりません。インポート指定子は URL のように厳密に扱われ、書いたとおりのファイルだけを探します。だから手元では CJS で通っていた相対 import が、type: "module" にした環境や .mjs にしたファイルでだけ落ちる、という形になります。
再現(最小構成)
package.json を ESM 指定にします。
{
"type": "module"
}
同じフォルダに util.js を置き、拡張子なしで import します。
// util.js
export const greeting = 'hello';
// index.js(type:module 下なので ESM 扱い)
import { greeting } from './util';
console.log(greeting);
node index.js を実行すると ERR_MODULE_NOT_FOUND が出て、終了コードは 1 になります。util.js は存在するのに、./util(拡張子なし)が解決できないためです。このとき Node は、同じ場所に util.js があるのを見つけて Did you mean to import "./util.js"? というヒントを添えます(実行中ファイルのあるディレクトリを基準に候補を探すため、別ディレクトリから実行するとヒントが出ないこともあります。古い Node 18 では引用符が無く候補も ../util.js のようにずれる版差があります)。
解決:相対 import に拡張子まで書く
ESM では、自作モジュールの相対 import に拡張子を必ず書きます。
// index.js(ESM のまま。拡張子 .js を付けた)
import { greeting } from './util.js';
console.log(greeting);
これで終了コード 0 で動きます。書く拡張子は、実際に配置されるファイルのものにします。
- 素の JavaScript なら、参照先が
util.jsなので'./util.js'。 - TypeScript を使っていても、実行されるのはコンパイル後の
.jsなので、ESM 出力では import に.jsと書きます(./util.tsではありません。ここは TypeScript 特有の紛らわしさなので後述)。
TypeScript を使っているとき(.ts ではなく .js と書く)
TypeScript のソースは util.ts なのに、import には './util.js' と書きます。奇妙に見えますが、Node が実行するのはコンパイル後の util.js なので、出力に合わせて .js を書くのが正解です。tsconfig.json を ESM 出力(例:"module": "nodenext" / "moduleResolution": "nodenext")にしていると、TypeScript もこの「.js 拡張子で書く」規則を前提に型チェックします。この構成(tsc で .js を出力し、その出力を Node で直接実行する)では、拡張子を省く・.ts と書く、のどちらもエラーになります。
なお、Vite や tsx のように バンドラ/実行ツールが .ts を直接解決する構成や、allowImportingTsExtensions(noEmit 時)を有効にしている構成では、あえて .ts 拡張子で書くのが正しい場合があります。ここで扱っているのは「tsc の出力を Node がそのまま実行する」ケースです。自分がどちらの構成かを先に確かめてください。
仕組み:ESM の指定子は「URL」
相対パスやローカルファイルの import は、ESM では最終的に file: URL として解決されます。URL に「拡張子を推測して補う」機能が無いのと同じで、ESM も書かれたパスをそのまま探します(no default extensions / no folder mains。ディレクトリの index.js も自動では探しません)。これは、どのファイルに解決されるかを曖昧さなく確定させるための設計です。CommonJS の便利な補完は、その代わりに解決のたびに拡張子や index.js を何度も試す必要がありました。
切り分け(うまくいかないとき)
- 拡張子を付けたのにまだ
ERR_MODULE_NOT_FOUND:パスの綴り・大文字小文字を確認してください。ESM は指定子を厳密に見るうえ、Linux(本番・CI で多い)はファイル名の大文字小文字を区別します。既定設定の macOS/Windows では通っても、Linux の CI で落ちることがあります。 ./dirのようにディレクトリを import している:ESM はindex.jsを自動で探しません。'./dir/index.js'のようにファイルまで指定してください(ディレクトリ指定はERR_UNSUPPORTED_DIR_IMPORTになります)。- npm パッケージの import で出た:相対 import ではなくパッケージ名(
import x from 'pkg')で出る場合は、そのパッケージ側のexports設定やサブパスの問題です。原因が別なので、この記事の「拡張子」対処では直りません。 - そもそも ESM にしたくなかった:
package.jsonの"type": "module"を外すか、そのファイルを.cjsにすれば CommonJS 扱いに戻り、拡張子なしの解決が復活します。ESM 扱いになる条件(自動検出を含む)は require is not defined in ES module scope の表も参照してください。 - 同じ ESM 化で別のエラーも出ている:
type: "module"にした途端に、exports is not defined(exports is not defined in ES module scope)や__dirname is not defined(__dirname is not defined in ES module scope)も一緒に出ることがあります。どれも原因は同じ(ESM 扱い)です。特に TypeScript の設定食い違いが絡む場合はexports記事も確認してください。