TL;DR
2つのファイルが互いにインポートし合っています。Node.jsがそれらを読み込む際、一方のモジュールがまだエクスポートされていないクラスを継承しようとするため、undefined が返されます。解決策:共有の基底クラスを独自のファイルに切り出し、そこからインポートします。
エラーの見た目
TypeError: Class extends value undefined is not a constructor or null
at Object.<anonymous> (/app/models/Dog.js:3:19)
at Module._compile (node:internal/modules/cjs/loader:1356:14)
スタックトレースは必ず class Foo extends Bar の行を指しています。Bar はその行が実行される時点で undefined になっています。エクスポートし忘れたからではなく、それをエクスポートするファイルがまだ読み込みを完了していないからです。
根本原因:循環ロード順の問題
CommonJSモジュールは読み込みを開始した瞬間にキャッシュされます。2つのファイルが互いにインポートし合うと、一方は相手の未完成・部分評価済みのバージョンを受け取ります。以下がトリガーとなる典型的なパターンです:
// Animal.js
const { Dog } = require('./Dog'); // ← まず Dog を読み込む
class Animal {}
module.exports = { Animal };
// Dog.js
const { Animal } = require('./Animal'); // ← ここでは Animal は {} — まだエクスポートされていない
class Dog extends Animal {} // TypeError: Class extends value undefined
module.exports = { Dog };
処理の流れはこうです:Node が Dog.js の読み込みを開始すると、Animal.js がトリガーされます。すると即座に Animal.js が Dog.js を require しますが、Dog.js はすでにキャッシュ上で読み込み中なので、Node はこれまでにエクスポートされた内容、つまり空のオブジェクト {} を返します。その空オブジェクトから { Animal } を分割代入すると undefined になります。エラーは extends が実行された瞬間に発生します。
修正方法1:基底クラスを独自ファイルに切り出す(推奨)
親クラスを、どの子クラスにも依存しないファイルへ移動します。シンプルで明快、常に機能します。
// base/Animal.js ← 新しいファイル、循環インポートなし
class Animal {
speak() {
return 'Some sound';
}
}
module.exports = { Animal };
// Dog.js
const { Animal } = require('./base/Animal'); // ← 素直なインポート
class Dog extends Animal {
speak() {
return 'Woof';
}
}
module.exports = { Dog };
// Animal.js(再エクスポートや他のロジック用に残す)
const { Animal } = require('./base/Animal');
const { Dog } = require('./Dog');
module.exports = { Animal, Dog };
覚えておきたい原則:基底クラスはサブクラスからインポートするべきではありません。Animal.js が Dog を参照する必要があるなら、それは設計上の問題のサインです。依存関係は上から下へ流れるべきであり、逆方向であってはいけません。
修正方法2:不要なインポートを削除する
親ファイルがサブクラスをインポートしているのが、再エクスポートのためだけという場合があります。そのインポートが本当にロード時に必要かどうかを確認しましょう。
// 修正前:Animal.js が機能上の理由なく Dog を引き込んでいる
const { Dog } = require('./Dog');
class Animal {}
module.exports = { Animal, Dog };
// 修正後:利用側が Dog を直接インポートする
class Animal {}
module.exports = { Animal };
修正方法3:require() 呼び出しを遅延させる(CommonJS のみ)
両方のクラスが互いを知る必要があるが、モジュール評価時点では不要という場合があります。その場合、require() をメソッド内に移動します:
// Animal.js
class Animal {
createDog() {
const { Dog } = require('./Dog'); // ← ロード時ではなく呼び出し時に実行される
return new Dog();
}
}
module.exports = { Animal };
createDog() が呼び出される頃には、両方のモジュールが完全に読み込まれ、キャッシュには完全なエクスポートが保持されています。ただし、これはあくまで回避策です。コードを再構成できるなら、そちらを優先してください。遅延 require はコードベース全体に依存関係を散在させ、リファクタリングを困難にします。
循環依存関係の自動検出
ファイルを読んで循環を探すのはやめましょう。madge なら数秒で見つけられます:
npx madge --circular --extensions js src/
TypeScript プロジェクトの場合:
npx madge --circular --extensions ts src/
出力はこのようになります:
Circular dependency found!
models/Dog.js → models/Animal.js → models/Dog.js
CIに組み込んで、循環依存が再び入り込まないようにしましょう:
npx madge --circular src/ && echo "No circular deps" || exit 1
ESM(import/export)に関する注意
ESM はキャッシュされたスナップショットではなくライブバインディングを使用するため、循環インポートの挙動が異なります。ただし、まったく同じエラーが発生することはあります。クラスがその宣言が評価される前に参照された場合、再び undefined に戻ります。
// ESM 版の同じ問題
// animal.mjs
import { Dog } from './dog.mjs'; // dog.mjs が animal.mjs をインポート → 循環
export class Animal {}
// 修正:base.mjs はサブクラスから何もインポートしない
export class Animal {}
根本原因も解決策も同じです。
修正の確認
npx madge --circular src/を実行し、循環がゼロと報告されることを確認します。- アプリを起動します:
node index.jsまたはnpm start—TypeErrorが消えているはずです。 - テストスイートを実行します:
npm test— クラスのインスタンス化テストがすべてパスするはずです。 - Jest ユーザーへ:テストでエラーが発生していた場合、まず
jest --clearCacheでモジュールキャッシュをクリアしてから再実行してください。
クイックチェックリスト
- 親クラスのファイルが子クラスからインポートしていませんか?削除しましょう。
- 2つのファイルが型やインターフェースを共有していませんか?
types.jsまたはbase/ディレクトリに切り出しましょう。 - すべてを再エクスポートするバレルファイル(
index.js)を使っていませんか?バレルファイルは循環依存を隠しやすいため、ソースファイルから直接インポートするようにしましょう。 madgeが循環を検出しましたか?チェーンの中で最も短いリンクを最初に修正し、その後再確認しましょう。

