はじめに
Node.js プロジェクトで ESM(ECMAScript Modules)に移行した、あるいは新規プロジェクトで "type": "module" を package.json に追加したとたん、以下のエラーが出て詰まった経験はないでしょうか。
ReferenceError: __dirname is not defined in ES module scope
または
ReferenceError: __filename is not defined in ES module scope
このエラーはいきなり全く見慣れない場所で出ることが多く、「さっきまで動いていたのになぜ?」となりがちです。この記事では原因と複数の解決策を体系的にまとめます。
なぜ起きるのか:CommonJS と ESM の根本的な違い
Node.js が従来使ってきた CommonJS(CJS) では、各ファイルはモジュールラッパー関数で包まれて実行されます。このラッパー関数が __dirname や __filename などの変数を引数として注入していました。
// Node.js が内部的にやっていること(CJS の場合)
(function(exports, require, module, __filename, __dirname) {
// あなたのコードはここに入る
});
一方、ESM ではこのラッパー関数が存在しません。ESM はブラウザとの互換性を重視した仕様であり、ファイルシステムの概念はブラウザには存在しないため、__dirname や __filename は ESM のスコープでは グローバル変数として存在しない のです。
ESM になる条件
以下のいずれかに該当するファイルは ESM として扱われます。
package.jsonに"type": "module"がある場合、拡張子.jsのファイル- 拡張子が
.mjsのファイル import/export構文を使っているファイル(TypeScript でトランスパイルする場合は設定次第)
解決策1(推奨): import.meta.url + fileURLToPath で再現する
ESM では __dirname の代わりに import.meta.url が使えます。これは現在のモジュールファイルの URL(file:///path/to/file.js 形式)を返します。
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
console.log(__dirname) // => /path/to/your/project/src
これを各ファイルの先頭に書くことで、CJS と同じように __dirname を使えます。
URL コンストラクタを使う(パス結合)
path.join(__dirname, '..', 'data') のような相対パス結合も、URL ベースで書き直せます。
import { fileURLToPath } from 'node:url'
// CJS での書き方
// const configPath = path.join(__dirname, '..', 'config.json')
// ESM での書き方
const configPath = fileURLToPath(new URL('../config.json', import.meta.url))
console.log(configPath) // => /path/to/your/project/config.json
new URL(相対パス, import.meta.url) を使うと、__dirname を経由せずに相対パス解決ができます。パス1つを解決するだけなら、こちらのほうがシンプルです。
解決策2: ユーティリティファイルにまとめて再利用する
複数ファイルで __dirname が必要な場合、毎回同じコードを書くのは面倒です。ユーティリティとして切り出すのがおすすめです。
// src/utils/esm-path.js
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
export function getDirname(importMetaUrl) {
return dirname(fileURLToPath(importMetaUrl))
}
export function getFilename(importMetaUrl) {
return fileURLToPath(importMetaUrl)
}
// src/some-module.js
import { getDirname } from './utils/esm-path.js'
const __dirname = getDirname(import.meta.url)
解決策3: require も使いたい場合は createRequire
ESM では require も使えません。require を使いたい場合(require.resolve を使うライブラリを呼び出す場合など)は createRequire で復活させられます。
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
// これで require が使える
const data = require('./data.json')
ただし、JSON のインポートについては後述の方法の方がシンプルです。
よくある追加ハマりポイント
ハマりポイント1: JSON ファイルが import できない
ESM で JSON を直接 import すると、Node.js 22 以前では以下のエラーになります。
TypeError [ERR_IMPORT_ASSERTION_TYPE_MISSING]: Module "file:///path/to/data.json"
needs an import attribute of "type: json"
解決策: インポートアサーション(Node.js 18+)または Import Attributes(Node.js 22+)を使う。
// Node.js 18〜21: assert 構文(非推奨になりつつある)
import data from './data.json' assert { type: 'json' }
// Node.js 22+: with 構文(推奨)
import data from './data.json' with { type: 'json' }
または createRequire か fs.readFileSync + JSON.parse を使う方法もあります。
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
const jsonPath = fileURLToPath(new URL('./data.json', import.meta.url))
const data = JSON.parse(readFileSync(jsonPath, 'utf-8'))
ハマりポイント2: サードパーティライブラリが __dirname を使っていて動かない
内部で __dirname を使っているサードパーティライブラリを ESM から呼び出すと、そのライブラリ自体がエラーになることがあります。
確認方法: エラースタックトレースを見て、node_modules 内のファイルが __dirname を参照していないか確認する。
解決策:
- そのライブラリの ESM 対応バージョンがあればアップデートする
createRequireで CJS として読み込む- 自分のコードだけを ESM にして、ライブラリは CJS のまま動かす(
.mjs拡張子を使い、package.jsonの"type": "module"を外す)
ハマりポイント3: TypeScript プロジェクトでの注意点
TypeScript で ESM を使う場合、tsconfig.json の設定が重要です。
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler", // または "NodeNext"
"target": "ES2022"
}
}
"module": "CommonJS"(デフォルト)のままだと、TypeScript はトランスパイル時に import を require に変換するため、__dirname が CJS として注入されます。この場合はエラーが出ません。
つまり TypeScript でエラーが出ていないのに Node.js 実行時にエラーが出る場合、TypeScript が CJS に変換しているが package.json の "type": "module" によって Node.js が ESM として解釈しようとしている、という設定の不一致が原因であることが多いです。
// package.json の確認
{
"type": "module" // ← これがあると .js は ESM として扱われる
}
// tsconfig.json の確認
{
"compilerOptions": {
"module": "CommonJS" // ← これだと TypeScript は CJS を出力する(矛盾が起きる)
}
}
TypeScript + ESM の組み合わせでは、module と moduleResolution を NodeNext か ESNext/bundler に統一しましょう。
// TypeScript + ESM の推奨設定
{
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist"
}
}
また、NodeNext モードでは .ts ファイルを import するときに拡張子を .js で書く必要があります(トランスパイル後のファイルを指定する)。
// NG: 拡張子なし(NodeNext モードではエラー)
import { something } from './utils'
// OK: .js 拡張子を付ける(.ts ファイルを import していても .js と書く)
import { something } from './utils.js'
まとめ
| 問題 | 解決策 |
|---|---|
__dirname is not defined |
fileURLToPath(import.meta.url) + dirname() |
__filename is not defined |
fileURLToPath(import.meta.url) |
| 相対パス解決 | new URL('../relative/path', import.meta.url) |
require is not defined |
createRequire(import.meta.url) |
| JSON import できない | with { type: 'json' } または JSON.parse(readFileSync(...)) |
| TypeScript との不一致 | module: "NodeNext" に統一 |
ESM への移行は最初に多くのエラーが出ますが、パターンを把握してしまえば対処は難しくありません。import.meta.url を起点にファイルシステム操作を再構築する、というのが ESM の基本的な考え方です。
段階的に移行する場合は、.mjs 拡張子を使って ESM ファイルと CJS ファイルを共存させる方法も有効です。プロジェクト全体を一度に書き換えるよりも、ファイル単位で少しずつ移行するほうがリスクが低いです。
