TypeScriptでモダンなアプリケーションを開発する際、避けて通れないのがモジュールシステムの互換性に関する問題です。
特に、外部ライブラリを導入した際に「デフォルトエクスポートが見つからない」といったエラーに遭遇し、設定を見直した経験がある方は多いのではないでしょうか。
このような問題を解決するために重要な役割を果たすのが、TypeScriptのコンパイルオプションであるesModuleInteropです。
本記事では、esModuleInteropの設定が具体的にどのような仕組みで動作し、なぜ現代のTypeScript開発において不可欠なのかを詳しく紐解いていきます。
CommonJSとESモジュールの間にある深い溝を理解することで、より堅牢なコードベースの構築に役立てていただければ幸いです。
JavaScriptモジュールの歴史と互換性の課題
JavaScriptには、長い歴史の中で複数のモジュールシステムが誕生してきました。
古くからNode.jsを中心に利用されてきたのがCommonJS(CJS)であり、その後ブラウザとサーバーの両方で標準化されたのがES Modules(ESM)です。
これら2つの規格は、モジュールの定義方法や読み込みのタイミング、エクスポートの仕組みが根本的に異なります。
特に大きな違いは、デフォルトエクスポートの扱いにあります。
CommonJSでは、module.exportsという単一のオブジェクトに値を代入することで、そのモジュール全体を一つのエンティティとして出力します。
一方、ESモジュールでは、export defaultという明示的な構文を用いて、特定の値を「デフォルト」として指定する仕様になっています。
この設計思想の違いにより、TypeScriptがESM形式のimport文をCJS形式のrequire文に変換する際、不整合が生じることがあります。
例えば、CJSで書かれたライブラリをESM形式の構文で読み込もうとすると、期待したオブジェクトが取得できない、あるいはエラーが発生するという事態が起こります。
esModuleInteropは、まさにこの「新旧のモジュールシステムが共存する環境」における橋渡しをするための設定です。
CommonJSとESモジュールの構造的差異
以下の表は、両方のモジュールシステムにおける基本的な書き方の違いをまとめたものです。
| 機能 | CommonJS (CJS) | ES Modules (ESM) |
|---|---|---|
| エクスポート | module.exports = ... | export default ... |
| インポート | const mod = require('...') | import mod from '...' |
| 読み込みタイミング | 実行時 (動的) | パース時 (静的) |
この表からも分かる通り、構文レベルで明確な違いがあるため、ツールチェーンによる補完が必要になります。
esModuleInteropフラグが解決する具体的な問題
TypeScriptの初期の設計では、ESMのimport構文をCJSのrequireに変換する際、非常にシンプルなマッピングを行っていました。
デフォルト設定(esModuleInterop: false)の状態では、import * as moment from 'moment'と記述すると、const moment = require('moment')に直接変換されます。
しかし、ここで問題が発生します。
CommonJS形式で提供されている多くのライブラリ(Reactやmoment.jsなど)は、module.exportsを関数やオブジェクトとして公開しています。
ESMの仕様上、import * asで取得したオブジェクトは、そのモジュールの「すべての名前付きエクスポートを含む名前空間」である必要があります。
そのため、CJSモジュールをそのままimport * asで受け取ると、関数そのものではなく「関数が含まれた名前空間オブジェクト」として扱われてしまい、実行時にエラーを吐くことになります。
この不一致を解消するために、かつてのTypeScriptユーザーはimport moment = require('moment')というTypeScript独自の構文を使うことを余儀なくされていました。
しかし、これはJavaScript標準の構文ではないため、他のツールとの相性が悪くなるという弊害がありました。
esModuleInteropを有効にすることで、JavaScript標準の構文を使いつつ、CJSモジュールを安全に読み込むことが可能になります。
esModuleInteropを有効にした時の内部挙動
この設定を有効にすると、TypeScriptコンパイラはコードのビルド時に特別な「ヘルパー関数」を挿入します。
主に2つの関数、__importDefaultと__importStarが生成され、これらがインポート処理を仲介します。
__importDefault ヘルパーの役割
デフォルトインポート(import foo from 'foo')を行う際、このヘルパーが呼び出されます。
もしインポート対象のモジュールがESM形式(__esModuleフラグを持っている)であれば、そのままの値を返します。
対象がCJS形式であれば、その値をdefaultプロパティに持つ新しいオブジェクトを作成して返します。
これにより、CJSライブラリであってもESMと同じようにdefaultから値を取得できるようになります。
// ソースコード
import fs from 'fs';
console.log(fs.readFileSync('test.txt'));
上記のコードがコンパイルされると、内部的には以下のようなロジックに変換されます(簡略化しています)。
// コンパイル後のイメージ
const __importDefault = (mod) => (mod && mod.__esModule) ? mod : { "default": mod };
const fs = __importDefault(require("fs"));
fs.default.readFileSync('test.txt');
このラップ処理のおかげで、fsモジュールがどのような形式であっても安全にアクセスできるのです。
__importStar ヘルパーの役割
名前空間インポート(import * as ns from 'foo')を行う際に使用されるヘルパーです。
この関数は、対象のモジュールのすべてのキーを走査し、新しいオブジェクトにコピーします。
その際、もし対象がCJSモジュールであれば、元々の値そのものもdefaultプロパティとして追加します。
これにより、ESMの仕様に準拠した挙動を擬似的に再現することができます。
allowSyntheticDefaultImportsとの密接な関係
esModuleInteropを語る上で欠かせないのが、allowSyntheticDefaultImportsという別のフラグです。
結論から言うと、esModuleInteropを有効にすると、自動的にallowSyntheticDefaultImportsも有効になります。
allowSyntheticDefaultImportsは、型チェックのレベルでの挙動を制御するものです。
実際のエクスポートにdefaultが含まれていなくても、デフォルトインポート構文を使ってもコンパイルエラーにしない、という許可を与えます。
しかし、このフラグ単体では「型チェックを通すだけ」であり、ランタイムでのコード変換は行いません。
一方、esModuleInteropは「ランタイムでの互換性を保つためのコード変換」と「型チェックの緩和」の両方を行います。
したがって、現代の開発環境においては、allowSyntheticDefaultImportsを個別に設定するよりも、esModuleInteropをtrueに設定するのが標準的なアプローチです。
tsconfig.jsonの設定例を確認してみましょう。
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2022",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
この構成にすることで、依存関係にあるライブラリの形式を過度に気にすることなく、クリーンなインポート構文を維持できます。
2026年におけるモジュール管理の推奨設定
2026年現在、Node.js環境においてもESMが標準的に使われるようになり、"type": "module"をpackage.jsonに指定するプロジェクトが増えています。
しかし、依然としてnpmエコシステムには膨大な量のCommonJSライブラリが残存しています。
そのため、esModuleInteropをfalseにするメリットはほとんどありません。
むしろ、この設定をfalseのままにしておくと、新しい開発者がプロジェクトに参加した際に、インポートの書き方で混乱を招く原因となります。
TypeScriptチームも、新しいプロジェクトではこのフラグを有効にすることを推奨しています。
プロジェクトの互換性を最大限に高めるために、以下の3点は常にセットで意識しておくと良いでしょう。
- esModuleInterop: true:CJSとの互換性を確保する。
- skipLibCheck: true:型定義ファイル(d.ts)内での互換性エラーを無視し、ビルドを安定させる。
- moduleResolution: “node” (または “nodenext”):Node.jsのモジュール解決ルールに従う。
これらの設定により、外部パッケージの内部構造に振り回されることなく、ビジネスロジックの開発に集中できる環境が整います。
発生しやすいエラーとトラブルシューティング
設定を正しく行っていても、特定のケースでエラーが発生することがあります。
最も多いのは、既存のコードベースで import * as を使っていた箇所を、esModuleInterop有効後にデフォルトインポートへ書き換えるのを忘れるケースです。
例えば、Reactを使用しているプロジェクトで以下のようなエラーが出ることがあります。
Error: This module is declared with using 'export =', and can only be used with a default import when using the 'allowSyntheticDefaultImports' flag.
このエラーは、「このモジュールはmodule.exports = ...形式で出力されているため、import * asではなく、デフォルトインポートを使ってください」という警告です。
このような場合は、インポート文を以下のように修正します。
// 修正前
import * as React from 'react';
// 修正後
import React from 'react';
コードがより簡潔になり、標準的なJavaScriptの書き方に近づくというメリットも得られます。
また、ビルドツール(WebpackやVite、esbuildなど)を併用している場合、それらのツール自体にもモジュール変換機能が備わっていることがあります。
TypeScript側の変換とツールの変換が競合して二重にラップされるような特殊な状況では、module設定をESNextに固定し、変換処理を後続のツールに任せる判断も必要になります。
まとめ
TypeScriptのesModuleInteropは、JavaScript界に存在する複雑なモジュールシステムの差異を吸収するための、非常に重要な機能です。
この設定を有効にすることで、CommonJSで書かれた古いライブラリも、最新のESモジュールと同じ感覚で扱えるようになります。
具体的には、コンパイル時に挿入されるヘルパー関数が、実行時のオブジェクト構造を適切に調整してくれるおかげで、開発者はモジュール形式の違いを意識する必要がなくなります。
現代のTypeScript開発において、このフラグを有効にすることは、もはや標準的な作法と言っても過言ではありません。
もしプロジェクトのtsconfig.jsonでこのフラグが設定されていない場合は、互換性の観点から導入を検討してみてください。
正しい設定を通じて、スムーズで生産性の高いコーディング環境を実現しましょう。
