import express from 'express' と書いたら「Module can only be default-imported using the ‘esModuleInterop’ flag」と言われ、言われたとおりに import * as express from 'express' に直したら今度は express() が呼べない。TypeScript で Node.js のパッケージを扱っていると、一度はこの往復にはまります。原因は、CommonJS と ES Modules という2つのモジュール方式のあいだにある「デフォルトエクスポート」の食い違いです。この記事では、esModuleInterop と allowSyntheticDefaultImports が実際に何を変えるのかを、出力される JavaScript まで見ながら整理します。
目次
CommonJS には「デフォルトエクスポート」という概念がない
Node.js が昔から使ってきた CommonJS では、モジュールが外に出す値は module.exports というオブジェクト1つだけです。そこにプロパティを足していけば名前付きエクスポートのように使えますし、module.exports 自体を関数で丸ごと上書きすれば「1つの関数を公開するモジュール」になります。Express はまさに後者です。
function createApplication() {
// ...アプリケーションを組み立てる
}
// モジュールそのものが関数になる
module.exports = createApplication;
// おまけとしてプロパティも生やせる
module.exports.Router = Router;
一方の ES Modules では、export default という専用の構文があり、それは default という名前のエクスポートとして扱われます。つまり import express from 'express' は、仕様どおりに読むと「express モジュールの default という名前のエクスポートをくれ」という意味になります。ところが CommonJS 側の module.exports には default というプロパティなどありません。素直に解釈すると undefined が返ってきてしまうわけです。
この「モジュール全体を1つの値として受け取りたい」という気持ちと、ES Modules の default の仕組みをどう橋渡しするか。それを引き受けるのが esModuleInterop と allowSyntheticDefaultImports の2つのオプションです。interop は interoperability(相互運用性)の略で、名前のとおり2つの方式をつなぐための設定です。
オプションを付けないとどうなるか
まず、両方のオプションを false にした状態を見てみます。TypeScript は型定義ファイル(.d.ts)を見て、そのモジュールが何をエクスポートしているかを判断します。@types/express の型定義は、CommonJS の module.exports = ... に対応する export = という構文で書かれています。
TS1259 / TS1192 が出るケース
// error TS1259: Module '".../@types/express/index"' can only be
// default-imported using the 'esModuleInterop' flag
import express from 'express';
const app = express();
TS1259 は「この型定義は export = で書かれている。デフォルトインポートしたいなら esModuleInterop を有効にしなさい」という、かなり親切なエラーです。似たものに TS1192(Module '"..."' has no default export.)があり、こちらは export = ですらなく、名前付きエクスポートしか持たないモジュールをデフォルトインポートしようとしたときに出ます。どちらも「そのモジュールに default は無い」と言っている点は同じです。
import * as で逃げると呼び出せなくなる
エラーを消すために、名前空間インポート(import * as)へ書き換えるのはよくある対処です。esModuleInterop が無効なとき、TypeScript は「名前空間オブジェクト = module.exports そのもの」として扱うので、これは型チェックを通りますし、実際に動きもします。
import * as express from 'express';
// esModuleInterop が false なら通る。true にすると TS2349 になる
const app = express();
ただしこれは、ES Modules の仕様から見ると本来おかしな書き方です。名前空間オブジェクトはエクスポートをまとめた入れ物であって、関数ではありません。実際、この .ts をそのまま ES Modules として実行する環境(バンドラや Node.js の ESM)に持っていくと、express is not a function で落ちます。つまり「tsc が CommonJS に変換してくれる間だけ動く」書き方であり、後述するとおり esModuleInterop を有効にすると TypeScript はこれを禁止します。
esModuleInterop を有効にすると何が変わるか
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
// CommonJS モジュールをデフォルトインポートできるようにする
"esModuleInterop": true,
"strict": true
},
"include": ["src"]
}
このオプションは、型チェックと出力コードの両方に手を入れます。やっていることは大きく3つで、順番に見ていきます。
__importDefault ヘルパーが挿入される
import express from 'express' を "module": "CommonJS" でコンパイルすると、次のような出力になります。
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
// 相手が ES Modules 由来ならそのまま、CommonJS なら default で包む
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = __importDefault(require("express"));
const app = (0, express_1.default)();
require('express') の戻り値をそのまま使うのではなく、__importDefault という小さな関数を通しています。中身は数行で、相手が ES Modules から変換されたものでなければ { default: mod } というオブジェクトで包み直すだけです。こうして「default プロパティにモジュール本体が入っている」状態を作るので、express_1.default() という呼び出しが成立します。
ここが allowSyntheticDefaultImports との決定的な違いです。esModuleInterop は型の話だけでなく、出力される JavaScript にこのヘルパーを足すところまでやってくれます。
__importStar が名前空間インポートを作り直す
import * as のほうにも __importStar というヘルパーが用意されます。こちらは、CommonJS のオブジェクトから default 以外のプロパティを新しいオブジェクトへコピーし、さらに元のモジュール本体を default として設定した、仕様どおりの名前空間オブジェクトを組み立てます。実装の細部は TypeScript のバージョンによって変わりますが(新しめの版では結果をキャッシュします)、考え方は次のとおりです。
// mod が ES Modules 由来ならそのまま返す。
// そうでなければ default 以外のプロパティを写して、
// mod 自身を default として持つ新しいオブジェクトを作る。
var __importStar = function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
for (var k in mod) {
if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) {
result[k] = mod[k];
}
}
result["default"] = mod;
return result;
};
結果として、import * as express from 'express' で受け取る express は「関数そのもの」ではなく「default に関数を持つオブジェクト」になります。実行時に express() と書けば当然エラーです。
名前空間インポートの呼び出しが型エラーになる
そこで esModuleInterop は、その実行時エラーを未然に防ぐために型チェックも厳しくします。名前空間インポートを関数として呼んだり new したりすると、コンパイル時に弾かれるようになります。
import * as express from 'express';
// error TS2349: This expression is not callable.
// Type 'typeof import("express")' has no call signatures.
const app = express();
// 正しい書き方
import expressDefault from 'express';
const app2 = expressDefault();
つまり esModuleInterop は「デフォルトインポートを許可する」だけの緩和策ではなく、「モジュールを1つの値として使いたいならデフォルトインポートで書け」という統一を強制する設定でもあります。名前空間インポートは、あくまで名前付きエクスポートをまとめて受け取るための構文に戻ります。
__esModule フラグが判断材料になっている
2つのヘルパーはどちらも mod.__esModule を見て挙動を変えていました。これは、ES Modules として書かれたコードを CommonJS へ変換したときに、「これは元々 ES Modules だった」という目印として付けられるプロパティです。TypeScript の CommonJS 出力の先頭に必ず現れる次の1行がそれです。
"use strict";
// この行があると「元は ES Modules」と判断される
Object.defineProperty(exports, "__esModule", { value: true });
exports.formatDate = formatDate;
この目印が付いているモジュールは、export default があればすでに exports.default に入っています。だからヘルパーは何もせずそのまま返します。逆に目印が無ければ本物の CommonJS なので、default で包む処理を行います。Babel なども同じ __esModule を付けるため、Babel でビルドされたパッケージとも噛み合うようになっています。
ちなみに Node.js 自身も、ES Modules から CommonJS パッケージを import したときは module.exports をデフォルトエクスポートとして見せます。esModuleInterop の考え方は、この Node.js の挙動とも方向がそろっています。
allowSyntheticDefaultImports との違い
allowSyntheticDefaultImports は、名前のとおり「合成された(synthetic)デフォルトインポート」を許す設定です。デフォルトエクスポートを持たないモジュールに対して import x from '...' と書くことを、型チェック上だけ許可します。出力される JavaScript には一切手を入れません。
| オプション | 型チェック | 出力される JavaScript |
|---|---|---|
allowSyntheticDefaultImports | デフォルトエクスポートが無いモジュールへの import x from を許可する | 変わらない。ヘルパーは挿入されない |
esModuleInterop | 上記に加えて、名前空間インポートの呼び出しを禁止する | __importDefault / __importStar ヘルパーが挿入される |
そして重要なのが、esModuleInterop を true にすると allowSyntheticDefaultImports も暗黙的に true になるという関係です。前者は後者を含んでいるので、両方を並べて書く必要はありません。
allowSyntheticDefaultImports だけを有効にすると危ない場面
型チェックだけを緩めるということは、「実際の変換は自分(または別のツール)が責任を持つ」という意味です。ここを取り違えると、コンパイルは通るのに実行時に落ちるコードが生まれます。"allowSyntheticDefaultImports": true かつ "esModuleInterop": false で "module": "CommonJS" 出力にすると、こうなります。
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const express_1 = require("express");
// express_1 は関数そのもの。default プロパティは存在しない
// → TypeError: express_1.default is not a function
const app = (0, express_1.default)();
ヘルパーが無いので require('express') の戻り値がそのまま使われ、存在しない default を呼びに行って落ちます。allowSyntheticDefaultImports を単独で使ってよいのは、tsc に出力させない構成、つまり Vite・webpack・esbuild などのバンドラが変換を担当し、tsc は "noEmit": true で型チェックだけを行うようなケースです。バンドラ側が自前で同等の interop 処理を入れてくれるので、TypeScript には型の許可だけを求めればよい、という理屈になります。
既定値と tsc –init が付ける設定
どちらのオプションも、書かなかったときの値が他の設定に連動します。TypeScript 5 系では次のようになっています。
| オプション | 既定で true になる条件 |
|---|---|
esModuleInterop | module が node16 / node18 / nodenext / preserve のとき。それ以外は false |
allowSyntheticDefaultImports | esModuleInterop が true のとき、module が system のとき、moduleResolution が bundler のとき |
"module": "CommonJS" や "module": "ESNext" を指定している場合は既定で false なので、自分で書く必要があります。逆に "module": "NodeNext" を使っているなら最初から有効です。細かい既定値はバージョンで変わりうる部分なので、迷ったら npx tsc --showConfig で実際に効いている値を確認するのが確実です。
なお npx tsc --init で生成される tsconfig.json には、コメントを外した有効な設定として "esModuleInterop": true が最初から書かれています。Next.js の create-next-app が生成する tsconfig.json にも同じ行が入っています。事実上、新規プロジェクトでは有効にしておくのが標準的な選択だと考えて差し支えありません。
ES Modules として出力するときの扱い
__importDefault のようなヘルパーは、CommonJS へ変換するときに必要になるものです。"module": "ESNext" のように import 文をそのまま残す設定では、変換自体が行われないのでヘルパーも挿入されません。"module": "NodeNext" で ES Modules 側と判定されたファイル(.mts や "type": "module" 配下の .ts)でも同じです。
この場合、実際の相互運用は実行環境である Node.js が引き受けます。esModuleInterop が担当するのは型チェックの側、つまり「CommonJS パッケージをデフォルトインポートで書けるようにする」という部分だけになります。ヘルパーが出ないからといって設定が無意味なわけではない、という点は押さえておいてください。
既存プロジェクトで有効化するときに詰まるところ
長く動いているプロジェクトで esModuleInterop を後から true にすると、たいてい大量のエラーが出ます。ただし内訳はだいたい決まっているので、順番に片付ければ機械的に対応できます。
import * as を呼び出している箇所をすべて直す
いちばん多いのがこれです。前述の TS2349 が、express・moment・debug といった「モジュール自体が関数」なパッケージを使っている箇所で一斉に出ます。対処は、名前空間インポートをデフォルトインポートに書き換えるだけです。
// 変更前
import * as express from 'express';
import * as path from 'path';
// 変更後:呼び出すものだけデフォルトインポートにする
import express from 'express';
// path は名前付きエクスポートを使うだけなので、そのままでよい
import * as path from 'path';
path.join() のようにプロパティ経由で使っているだけなら、名前空間インポートのままで問題ありません。エラーになるのは、名前空間そのものを () や new で使っている箇所だけです。
import = require() という選択肢
TypeScript には、CommonJS を読むための専用構文 import x = require('...') も用意されています。これは esModuleInterop の値に関係なく、常に module.exports をそのまま受け取ります。「このモジュールは CommonJS だ」という意図がコード上に明示される点が利点です。
import express = require('express');
const app = express();
ただしこの構文は ES Modules として出力するファイルでは使えません。将来 ES Modules へ移行する可能性があるなら、esModuleInterop を有効にしてデフォルトインポートに寄せておくほうが、書き換えの手間は少なくて済みます。
出力する JavaScript が変わることを意識する
esModuleInterop は型チェックだけの設定ではなく、生成される .js の中身を変えます。ライブラリを公開しているプロジェクトなら、ビルド成果物が変わることを意味します。有効化したあとは型エラーが消えたことで満足せず、実際にビルドして動作を確認してください。特に、名前空間インポート経由でモジュール本体を他の関数へ渡していたようなコードは、型エラーにならないまま挙動だけ変わる可能性があります。
大きなプロジェクトで一気に直すのが難しい場合は、先に allowSyntheticDefaultImports だけを有効にしてデフォルトインポートへの書き換えを進め、最後に esModuleInterop へ切り替えるという段取りも取れます。ただし、その途中の状態で tsc に CommonJS を出力させると先ほどの default is not a function を踏むので、その手順が使えるのはバンドラを併用している構成に限られます。
新しめのオプションとの関係
TypeScript 5.0 で追加された verbatimModuleSyntax は、type が付いていないインポート・エクスポートを書いたとおりに残す設定です。import 文の省略や書き換えを減らして出力を予測しやすくするもので、esModuleInterop を置き換えるものではありません。両者は目的が違うので、併用しても構いません。
また TypeScript 5.4 で追加された "module": "preserve" は、変換をバンドラに任せる構成向けの値です。この値を選ぶと esModuleInterop の既定値が true になるため、明示しなくてもデフォルトインポートが書けます。いずれにせよ、いま新しく組む構成では esModuleInterop が有効な状態が前提になっている、と考えておくとよいでしょう。
まとめ
esModuleInterop は、module.exports しか持たない CommonJS モジュールを import x from '...' で読めるようにするオプションです。型チェックを緩めるだけでなく、__importDefault / __importStar というヘルパーを出力コードに挿入して、実行時にも辻褄が合うようにしてくれます。判断材料になっているのは、ES Modules 由来かどうかを示す __esModule フラグです。
allowSyntheticDefaultImports は、そのうち型チェックの許可だけを行う設定で、出力は変えません。tsc に JavaScript を出力させるなら esModuleInterop を選び、バンドラに変換を任せる構成でのみ allowSyntheticDefaultImports 単独が意味を持ちます。esModuleInterop は allowSyntheticDefaultImports を暗黙で有効にするので、両方書く必要はありません。
既存プロジェクトで有効化するときは、import * as express from 'express' のように名前空間インポートを呼び出している箇所が TS2349 になります。これはバグの温床だった書き方が可視化されただけなので、デフォルトインポートへ書き換えるのが正しい対処です。出力される JavaScript も変わるため、型エラーが消えたら実際にビルドして動作確認まで済ませてください。