TypeScript の module オプションを改めて整理する (TS 5.9 対応)
この記事は 🎄ファインディエンジニア #1 Advent Calendar 2025 10日目の投稿です。
昨日はなかじさんの「答え」ではなく「考え方」をー未経験エンジニアがメンターに感謝していること3つでした。素敵な記事なのでぜひお読みください!

はじめに
tsconfig.json の各設定について、あまり理解せず設定していたり、デフォルトのままという方も多いのではないでしょうか。私もその一人で、ビルドが通るのにランタイムエラーが発生してハマることも多くありました。そこで、各オプションを調べていく中で module がとても奥が深いことを知りました。
この記事では、TypeScriptにおける module オプションについて、その基本的な役割と設定時に注意すべきポイントを紹介します。
module オプションは、TypeScriptが出力するモジュール形式を指定するための重要な設定項目です。ES Modules (ESM) やCommonJS (CJS) の違い、Node.jsにおける実行時の挙動、さらにはビルド結果の違いを正確に理解しておかないと、意図しないトラブルに見舞われるリスクが高まります。
そこで、module オプションがもたらす挙動の微妙な違いとその影響を解説し、さらにTypeScript 5.8で導入された "NodeNext" の仕様変更と、TypeScript 5.9で追加された "Node20" がどのような影響を及ぼすのかについても触れます。
この記事はTSKaigi 2025での発表内容をベースに、その後リリースされたTypeScript 5.9のアップデートを反映してまとめたものです。発表資料もぜひご覧ください。
module オプションとは
module オプションとは、コンパイル後に出力されるJavaScriptのモジュール形式を指定する設定です。
Node.jsやブラウザが実行時にモジュールを正しく解釈するために必要な設定となります。
設定においては、ESMやCJSの特性、Node.jsのモジュール解決ルールなどの背景知識が必要です。設定を誤ると、予期しないビルドエラーやランタイムエラーの原因になり得ます。
そのため、プロジェクトで採用しているツールチェーンごとに適した設定をすることが重要です。
{
"compilerOptions": {
"module": "ESNext"
}
}
module オプションにおける2つの役割
module オプションは、単純なモジュール形式の指定だけでなく、2つの役割を持っていることに注意が必要です。
それは、① モジュール形式の指定と、② モジュール解決戦略の指定です。
① モジュール形式の指定
"CommonJS" や "ESNext" などを指定した場合、出力されるコードはその形式に固定されます。つまり、すべてのファイルが強制的にCJSまたはESMへと変換されます。
例えば、 "CommonJS" を指定すると、すべてのモジュールがCJS形式で出力され、import 文は require() に変換されます。
② モジュール解決戦略の指定
"Node20" や "NodeNext" を指定した場合、Node.jsの実行環境に合わせてモジュール形式が出し分けられます。
例えば、"Node20" を指定すると、Node.js 20の挙動に基づきファイルの拡張子(.mjs/.cjs)や package.json の "type" 設定によって、ファイルごとにESMかCJSかが決定されます。モジュール形式がプロジェクト全体で1つに固定されません。
また、"Preserve" を指定した場合は、入力されたTSに書かれたモジュール構文を変換せず、そのまま出力します。解決はバンドラー等に委ねられます。
このように、 module オプションには2つの側面があることを理解するのが第一歩です。
① モジュール形式の指定
ここからは、まず① モジュール形式の指定について詳しく見ていきます。
【背景】モジュールについて
モジュールとは、コードを機能ごとに分割し、それらを必要に応じて連携させて再利用するための仕組みです。
JavaScriptの世界では、代表的なモジュールシステムとして ES Modules (ESM) と、CommonJS (CJS) の2つがあります。
※ UMD, AMD, SystemJSなど他のモジュールシステムも存在しますが、ここでは主にNode.jsエコシステムで使われるESMとCJSに焦点を当てます。
ES Modules (ESM)
JavaScript言語自体の標準仕様 (ECMAScript) として定められたモジュールシステムです。import, export を使用します。
import { a } from "./b.js";
export const c = a * 2;
CommonJS (CJS)
主にNode.js環境のために作られ、長年サーバーサイドJavaScriptの標準として使われてきたシステムです。require, module.exports を使用します。
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.c = void 0;
const constants_1 = require("./b.js");
exports.c = constants_1.b * 2;
ESM と CJS の違い
Node.jsのエコシステムにおいては長らくCJSがデファクトスタンダードでしたが、近年はESMへの移行が加速しています。
CJSは実行時に同期的にファイルを読み込むのに対し、ESMは解析時に静的に依存関係を解決できます。このため、ESMの方がTree Shakingなどの手法が利用できツールによる最適化がしやすいというメリットがあります。
また、ESMはECMAScript標準で定義されていることから、Node.js・ブラウザを含め、JavaScriptエコシステム全体で幅広く利用可能です。
近年では「Pure ESMパッケージ」と呼ばれる、ESMでしか提供されないライブラリも増えてきました。これはエコシステムの変化への対応や、二重管理によるメンテナンスコストの削減を目的としています。
新しいプロジェクトではESMが採用され、既存のCJSプロジェクトにおいてもESMへ移行するケースが増えています。
module オプションで "ES*" を指定した場合の挙動
module オプションで "ES2022" や "ESNext" を指定すると、ECMAScriptの仕様に則る形でモジュールが出力されます。
例えば、"ES2022" を指定した場合、Top-level awaitなど ES2022 で利用可能な機能がそのまま出力されます。
"ESNext" を指定した場合は、TypeScriptがサポートする最新のECMAScript仕様に加え、プロポーザルのステージ3以上の機能も利用可能になります。
トランスパイル(バンドル)せずにそのまま利用する場合は、実行環境の対応バージョンに注意してください。
| 設定値 | Dynamic Import | Top-level await | 今後追加される機能 |
|---|---|---|---|
"ES2015" |
✗ | ✗ | ✗ |
"ES2020" |
○ | ✗ | ✗ |
"ES2022" |
○ | ○ | ✗ |
"ESNext" |
○ | ○ | ○ |
② モジュール解決戦略の指定
ここからは、② モジュール解決戦略の指定について詳しく見ていきます。
【背景】Node.js のモジュール解決について (Dual Packages)
Node.jsではCJSとESMの両方をサポートしており、次のルールでそのファイルがCJSかESMかを判定します。
- 拡張子
.cjsは、CJSとして扱う - 拡張子
.mjsは、ESMとして扱う - 拡張子
.jsは、package.jsonの"type"フィールドで判定する-
"type": "module"の場合はESMとして扱う -
"type": "commonjs"または未指定の場合はCJSとして扱う
-
また、Node.jsのバージョンにより利用可能なインポート方法や機能が異なります。
| Node.js | インポート アサーション (廃止) |
インポート 属性 |
JSONの インポート構文 |
CJSから ESMへの require()
|
|---|---|---|---|---|
| v16 | 16.14.0+ | ✗ | assert { type: "json" } |
✗ |
| v18 | ○ | ✗ | assert { type: "json" } |
✗ |
| v20 | 非推奨 | ○ |
assert { type: "json" } with { type: "json" }
|
✗ |
| v22 | 廃止 | ○ |
assert { type: "json" } with { type: "json" }
|
22.12.0+ |
module オプションで "Node*" を指定した場合の挙動
TypeScriptの module オプションで "Node20" や "NodeNext" を指定すると、前述したNode.jsのネイティブな挙動を模倣してトランスパイルを行います。
つまり、出力ファイルの拡張子や package.json の設定に合わせて、CJSなのかESMなのかを自動的に切り替えて出力します。
例えば、ESM (.mts) からCJS (.cts) を読み込むと、CJS側の exports オブジェクトは default export として扱われます。
なお、"NodeNext" を指定した場合は、TypeScriptがサポートする最新のNode.jsの仕様に追従します。
| 設定値 | JSONインポート時の 出力 |
JSONの インポート構文 |
CJSから ESMへの require()
|
|---|---|---|---|
"Node16" |
属性なし | 不要 | ✗ |
"Node18" |
assert { type: "json" } |
要 { type: "json" }
|
✗ |
"Node20" |
with { type: "json" } |
要 { type: "json" }
|
✗ |
"NodeNext" |
with { type: "json" } |
要 { type: "json" }
|
○ |
トラブル事例
これらの設定を誤るとどうなるか、具体的なトラブル事例を見てみましょう。
モジュールの不整合によるランタイムエラー
よくあるトラブルとして、正常にトランスパイルできているように見えても、実行時にランタイムエラーが発生することがあります。
例えば、CJSがESMとして誤って処理された場合、次のようなエラーが発生します。
ReferenceError: require is not defined
これは、ESM環境やブラウザには require 関数が存在しないためです。
一方で、ESMがCJSとして処理された場合には次のようなエラーが発生します。
SyntaxError: Cannot use import statement outside a module
これは、CJS環境では import 文を用いたESMの構文が解釈できないためです。
最終的に出力されるコードがESMなのかCJSなのかを常に意識し、ランタイムエラーが発生する場合は、出力された JavaScript ファイル(dist/ など)の中身を確認することが重要です。
TypeScriptのアップデートによる挙動変化
Node.js 22より、試験的機能としてCJSからESMを同期的に require() できる機能が追加されました。
これは、CJSからESMへの移行負担を軽減することを目的としています。
TypeScript 5.8以降、"NodeNext" ではこれに追従してCJSからESMを require() することが型チェック上許可されるようになりました。しかし、この機能はNode.js 22.12.0以降でのみ動作するため、新たにこの構文を使ったコードを書くと、Node.js 22.12.0未満ではランタイムエラーになります。
このように、"NodeNext" や "ESNext" のような変動する設定値を使用する場合は、TypeScriptのバージョンアップによって出力結果や対応要件が変わる点に注意が必要です。
現時点での module オプションの選び方
フロントエンド向け
- Vite, Next.js, webpack等のバンドラを利用する場合、バンドラーやフレームワークの推奨設定(テンプレート)に従う
- 基本的には
"ESNext"または"Preserve"が使用されるケースが多い - バンドラーが解決するため、Node.js独自の解決ルールを模倣する
"NodeNext"や"Node20"は基本的に指定しない
Node.js プロジェクト向け
- Node.jsのモジュール解決ルールに準拠する
- 基本的には
"NodeNext"または"Node20"など実行バージョンに応じて設定する - Node.js独自の解決ルールを模倣しない
"ESNext"や"CommonJS"は基本的に指定しない
まとめ
module オプションは出力されるモジュールに関する重要な設定です。
-
① モジュール形式の指定 (
ESNext,CommonJSなど) -
② モジュール解決戦略の指定 (
NodeNext,Preserveなど)
この2つの役割を区別して理解することが重要です。
また、設定にあたっては次の点に注意しましょう。
- JavaScriptモジュールシステムの背景知識を持つ
- ESMとCJSの違いや、相互運用のルールを理解する
-
"Node*"はNode.jsランタイム以外では原則使用しない- フロントエンド環境ではバンドラーに委ねる設定(
Preserve等)を選ぶ
- フロントエンド環境ではバンドラーに委ねる設定(
-
"*Next"はTypeScriptのアップデートで仕様が変わる- バージョンアップ時にはリリースノートを確認し、挙動変更に注意する
モジュールシステムは今後も変化し続けるため、継続的なキャッチアップが重要です。
明日はKyohei Shibuyaさんの記事が投稿されます。お楽しみに!
Discussion