Table of Contents

TypeScript の module オプションと moduleResolution オプション

今まで、なんとなく理解して使っていた TypeScript のmoduleオプションとmoduleResolutionオプション。 一抹の気持ち悪さを感じながら「動けばいいか」で済ませていた。

ここでは話さないが、あるきっかけがあり心を入れ替えて、今回、きちんと勉強してみることにした。

TypeScript コンパイラは、TypeScript で書かれたソースコードを JavaScript へ変換し出力するコンパイラ。 本記事はユースケース(コンパイル後の JavaScript の使われ方)毎に TypeScript の module オプションと moduleResolution オプションをどう設定すべきか?についてまとめたもの。 あらゆるインターネット上の記事を参考にし、自分でソースコードを書いて実際に試してみた結果をここに記す。 「こういうことをやりたいとき、この 2 つのオプションをどう設定すべきか?」という観点で記事をまとめることにした。

module オプションと moduleResolution オプションについてのバリエーションや歴史的背景についてはあまり詳しく述べない。 調べてみてわかったことだが、その歴史的背景は膨大にあり、それに起因するバリエーションも膨大にあるため。

また、module オプションの設定値commonjs,umd,amd,system,noneについては述べない。2024 年現在、新しく TypeScript 言語を用いたプロジェクトを始動する場合においては、これらの設定値を使うことは少ないため。

お願い

module オプションと moduleResolution オプションについて勉強すればするほど分からなくなる。もし本記事に間違いがあったら、X とかでそっと教えていただけるとありがたいです・・・。

サンプルコードの実行環境

  • Node.js v20
    • 本当は Node.js のバージョンによっては・・・みたいな話をした方が良いのだけど、話が無限に発散しそうなため、本記事では Ndoe.js v20 に限定させていただく。まぁ Node.js v20 での挙動を理解できていれば、他のバージョンになっても応用は効くであろうから。
  • TypeScript v5

TypeScript コンパイラと module オプション

TypeScript コンパイラは、TypeScript で書かれたソースコードを JavaScript へ変換し出力するコンパイラ。

必要に応じてダウンレベルや module 形式の変換が行われる。 module オプションは module 形式の変換に関連する設定。

module オプションは「コンパイル後の JavaScript のモジュール形式を指定する」とリファレンスでは説明されているが、次のように理解した方が良い(・・・と自分は思った)。

module オプションはコンパイル後の JavaScript のユースケースを指定するものであり、TypeScript は、module オプションの設定値に応じて「コンパイル後の JavaScript がどう使われるか」を考慮して良い感じの JavaScript を出力する。

このように理解した方が良い理由として、module オプションを node16 または nodenext に設定した場合の一見すると不思議な挙動による。module オプションをこの 2 つのどちらかに設定した場合、コンパイル後の JavaScript のモジュール形式は TypeScript だけの情報からでは決定されず、実行環境となる Node.js の設定値も加味した上で決定される。TypeScript のこの挙動は一見すると不思議に見えるが、JavaScript 界隈の現状を鑑みると、このようにせざるを得ない。この辺の苦悩については、こちらの記事が言い当てている。

module オプション

module オプションの設定値は 2 種類ある(この 2 種類以外の設定値については、本記事においては説明を省略させていただきます mm)。

  • node16,nodenext、以降nodeXXと呼称する
    • このオプションを指定した場合、「コンパイル後の JavaScript は Node.js v16 以上によって実行される」という想定で TypeScript がコンパイルされる。
  • es2015,es2020,es2022,esnext、以降esXXXXと呼称する
    • このオプションを指定した場合、「コンパイル後の JavaScript は、ES Module を扱うことのできる何らかの中間変換(バンドル、Web サーバーから JavaScript ファイルの配布など)を介して、何らかの実行環境(ブラウザ、Node.js、Electron など)で実行される」という想定で TypeScript がコンパイルされる。

TypeScript のユースケースとは

図 1

図 1 は、現在の TypeScript のユースケースである。

  • 1->2->5
    • TypeScript は Node.js において実行されるソースコードを書くために使われる。module オプションの nodeXX において想定されているユースケース。
  • 1->2->4->7->10
    • TypeScript は Node.js において実行されるソースコードを書くために使われる。このユースケースにおいては、module オプションを nodeXX にすれば良いとは限らない。なぜなら、コンパイル後の JavaScript は、Node.js ではなくバンドラーによって処理されるためである。バンドラーが扱えるモジュール形式に応じて、module オプションを esXXXX にした方が良いかもしれない(そして、moduleResolution を bundler とする)。
  • 1->2->4->7->9
    • TypeScript はブラウザ上において実行されるソースコードを書くために使われる。このユースケースにおいては、module オプションを nodeXX にすれば良いとは限らない。なぜなら、コンパイル後の JavaScript は、Node.js ではなくバンドラーによって処理されるためである。バンドラーが扱えるモジュール形式に応じて、module オプションを esXXXX にした方が良いかもしれない(そして、moduleResolution を bundler とする)。
  • 1->2->3->6->8
    • TypeScript はブラウザ上において実行されるソースコードを書くために使われる。このユースケースにおいては、module オプションを esXXXX にすると良いかもしれないが・・・どうなんだろう。そもそもの現状として、TypeScript が出力した JavaScript をバンドラーを介さずにブラウザで実行できる状態にする、なんていうことを、あまりしない。module オプションを esXXXX とした場合におけるコンパイル後の JavaScript は、多くの場合、ブラウザ上でそのまま実行することはできない。実行するためには、index.html は必要となるし、依存する全てのモジュールを適切なパスへ設置する必要もある。面倒臭すぎる・・・。

TypeScript コンパイラの moduleResolution オプション

さて。図 1 において、モジュール間の依存関係を解決しているのは誰だろう?答えは「コンパイラ」・・・その通り!しかしそれだけではない。「人力による何らかの変換」「モジュールバンドラー」「Node.js」もモジュール間の依存関係を解決している。なぜなら、JavaScript はスクリプト言語だからだ。実行環境上でも依存関係の解決が実施される。

moduleResolution オプションは「モジュールの依存関係の解決アルゴリズムを指定するためのオプション」とあるが、これについても次の 2 つをセットで理解しておいた方が良い。

要は「コンパイル後の JavaScript が実行環境上で適切にモジュールの依存関係を解決できること」を満たすように、「TypeScript はモジュールの依存関係を解決する」ということ。

従って、このオプションはコンパイル後の実行環境を指定するための module オプションと密接に関係していて、module オプションの設定値に応じて moduleResolution オプションが自動的に設定されたり、特定の module オプション設定値に対して使用不可能な moduleResolution オプションがあったりする。

要は、module オプションと moduleResolution オプションを組み合わせて使おう、ということ。

module オプションを nodeXX とした場合に、moduleResolution オプションをどう設定すべきか

module オプションを nodeXX とした場合、moduleResolution オプションは自動的に nodeXX となるため、明示的に設定しなくて良い(moduleResolution オプションを bundler に変えることができるが、積極的にそうする理由がわからなかった・・・orz)

moduleResolution オプションが nodeXX である場合、モジュールの依存関係の解決アルゴリズムは「コンパイル後の JavaScript が Node.js 環境上で適切にモジュールの依存関係を解決できること」を満たすようになる。

従って、TypeScript で書かれたソースコードの import 文のモジュール指定子には、コンパイル後の JavaScript のファイル拡張子が必要となる。

サンプルコード

TypeScript のソースコード

 1// hoge.mts
 2export function sayHoge() {
 3    console.log("hoge");
 4}
 5
 6// hoge.cts
 7export function sayHoge() {
 8    console.log("hoge");
 9}
10
11// main.mts
12import { sayHoge } from "./hoge.mjs";
13sayHoge();
14
15// main.cts
16import { sayHoge } from "./hoge.cjs";
17sayHoge();

import のモジュール指定子が./hoge.mjs./hoge.cjsとなっていて、コンパイル後の JavaScript のファイル拡張子となっている。このファイル拡張子を省略したり、TypeScript のファイル拡張子(.mts、cts)を指定したりできない。

module オプションを esXXXX とした場合に、moduleResolution オプションをどう設定すべきか

module オプションを esXXXX とした場合、moduleResolution オプションは自動的に classic となる。がしかし、リファレンスによれば「classic を使うな」と書いてある、かつ、moduleResolution オプションを nodeXX にはできない。では何を使用したら良いか?使用可能な moduleResolution オプションは node,node10,bundler のいずれかである。可能であれば bundler を使用したら良い。

moduleResolution オプションが bundler である場合、モジュールの依存関係の解決アルゴリズムは「コンパイル後の JavaScript がバンドラー上で適切にモジュールの依存関係を解決できること」を満たすようになる。

しかしながら、バンドラー上で依存関係を解決できること、と言うけど、依存関係の解決方法はバンドラーによって違ってくるのでは?と思ってしまう。確かにそうなんだけど、この辺り、調べてもどうしてもよく分からなかった部分である・・・。

(結論)Node.js 上で実行される JavaScript を Bundler を使用せずに出力したい場合

ユースケース 1->2->5 向け。

コンパイラは、コンパイル後の TypeScript は Node.js v16 以上で実行されることを考慮し良い感じに JavaScript を出力する。 Node.js v16 以上では ES Module と CommonJS Module の両方が使用可能であるため、コンパイラは Node.js の設定によって出力する JavaScript のモジュール形式をいい感じにする。

  • tsconfig.json
    • module を nodeXX とする。
    • moduleResolution は変更なし(module を node16 または nodenext にした場合、自動的に moduleResolution も node16 または nodenext となるため)。

コンパイル後の JavaScript が ES Module になるか、CommonJS Module になるか、については、Node.js の設定やファイルの拡張子による。

JavaScript のモジュール形式を ES Module だけとしたい場合、package.json の type を module とする

  • package.json
    • type を module にすると ES Module となる。

サンプルコード

JavaScript のモジュール形式を CommonJS Module だけとしたい場合、package.json の type を commonjs とする

  • package.json
    • type を commonjs にすると CommonJS Module となる。

サンプルコード

JavaScript のモジュール形式を ES Module、CommonJS Module の両方を併用としたい場合、ファイル拡張子を変える

TypeScript は、.mts という拡張子を持つソースコードを ES Module の JavaScript として出力し、出力後の JavaScript のソースコードの拡張子を.mjs とする。

TypeScript は、.cts という拡張子を持つソースコードを CommonJS Module の JavaScript として出力、出力後の JavaScript のソースコードの拡張子を.cjs とする。

サンプルコード

(結論)Node.js 上で実行される JavaScript を Bundler を使用して出力したい場合

ユースケース 1->2->4->7->10 向け。

コンパイル後の JavaScript のモジュール形式をバンドラーが扱えるものにする必要がある。バンドラーが扱えるモジュール形式が何か?はバンドラーによるので、バンドラーのリファレンスを確認すること。

今回、サンプルコードでは Webpack をバンドラーとして利用した。

バンドラーとして Webpack(ts-loader なし)を使用する場合(module=nodeXX)

ts-loader 使いましょう、と言いたいところであるが、やってみる。

  • tsconfig.json
    • module を nodeXX とする。
    • moduleResolution は変更なし(module を node16 または nodenext にした場合、自動的に moduleResolution も node16 または nodenext となるため)。
  • (Webpack 特有)JavaScript が CommonJS Module 形式である場合tree shakingされないため、ES Module 形式にすることが推奨されている。
  • 注意 TypeScript を Webpack で扱いたいのであればts-loaderを用いた方が良い。サンプルコードでは ts-loader を用いていませんが、その理由は勉強用のソースコードだから。

サンプルコード

バンドラーとして Webpack(ts-loader なし)を使用する場合(module=esXXXX)

ts-loader 使いましょう、と言いたいところであるが、やってみる。

  • tsconfig.json
    • module を esXXXX とする。
    • moduleResolution を bundler とする(module を esXXXX にした場合、自動的に moduleResolution は classic という残念な設定になってしまうため)。
  • 注意 TypeScript を Webpack で扱いたいのであればts-loaderを用いた方が良い。サンプルコードでは ts-loader を用いていませんが、その理由は勉強用のソースコードだから。

サンプルコード

バンドラーとして Webpack(ts-loader あり)を使用する場合

  • tsconfig.json
    • module を esXXXX とする。
    • moduleResolution を bundler とする(module を esXXXX にした場合、自動的に moduleResolution は classic という残念な設定になってしまうため)。

module を node16 にするとコンパイルできない(一応理由はサンプルコードの README.md に書いた)。

サンプルコード

(結論)ブラウザ上で実行される JavaScript をバンドラーを使用せずに出力したい場合

ユースケース 1->2->3->6->8 向け。

バンドラー使いましょう、と言いたいところであるが、やってみる。

「人力による何かの変換」として、index.html を配置する、http-server から.html、.js ファイルを配信する、をやっている。

  • tsconfig.json
    • module を esXXXX とする。
    • moduleResolution を bundler とする(module を esXXXX にした場合、自動的に moduleResolution は classic という残念な設定になってしまうため)。

サンプルコード

(結論)ブラウザ上で実行される JavaScript をバンドラーを使用して出力したい場合

ユースケース 1->2->4->7->9 向け。

バンドラーとして Webpack(ts-loader なし)を使用する場合(module=nodeXX)

ts-loader 使いましょう、と言いたいところであるが、やってみる。

  • tsconfig.json
    • module を nodeXX とする。
    • moduleResolution は変更なし(module を node16 または nodenext にした場合、自動的に moduleResolution も node16 または nodenext となるため)。
  • (Webpack 特有)JavaScript が CommonJS Module 形式である場合tree shakingされないため、ES Module 形式にすることが推奨されている。
  • 注意 TypeScript を Webpack で扱いたいのであればts-loaderを用いた方が良い。サンプルコードでは ts-loader を用いていませんが、その理由は勉強用のソースコードだから。

サンプルコード

バンドラーとして Webpack(ts-loader なし)を使用する場合(module=esXXXX)

ts-loader 使いましょう、と言いたいところであるが、やってみる。

  • tsconfig.json
    • module を esXXXX とする。
    • moduleResolution を bundler とする(module を esXXXX にした場合、自動的に moduleResolution は classic という残念な設定になってしまうため)。
  • 注意 TypeScript を Webpack で扱いたいのであればts-loaderを用いた方が良い。サンプルコードでは ts-loader を用いていませんが、その理由は勉強用のソースコードだから。

サンプルコード

バンドラーとして Webpack(ts-loader あり)を使用する場合

  • tsconfig.json
    • module を esXXXX とする。
    • moduleResolution を bundler とする(module を esXXXX にした場合、自動的に moduleResolution は classic という残念な設定になってしまうため)。

サンプルコード

(おまけ)Webpack の ts-loader は tsc コマンドを使用していないが、typescript パッケージの TypeScript API を用いてコンパイルしている

ts-loader を用いた場合、TypeScript のソースコードを Webpack がどう処理するのか?が気になったので調べてみたが、ts-loader の内部では、typescript パッケージの TypeScript API を用いてコンパイルしていた(参考)。

コンパイラを切り替えたい場合、ts-loader のcompiler オプションによって切り替えることができる。compiler オプションのデフォルト値が typescript となっているため、compiler オプションを何も指定しない場合、typescript パッケージの TypeScript API を用いてコンパイルされる。

ts-loader を用いた場合、一見すると Webpack が TypeScript のソースコードを直接コンパイルしているように見えるが、そうではなく、TypeScript コンパイラでコンパイルしたソースコードをバンドリングしている。

(おまけ)TypeScript における import、export の記法

TypeScript は JavaScript のスーパーセットなため、JavaScript と同じ書き方で import と export を書くことができるはずだが、実は TypeScript における import と export の書き方がある。リファレンスに書かれている。

ES Module ライクな記法

JavaScript の ES Module とほぼ同じ。

1// module_like_esm.mts
2export const a = "This is esm module";
3// main.mts
4import { a } from "./module_like_esm.mjs";

CommonJS Module ライクな記法

Node.js で使用されている CommonJS Module と若干違う。

1// module_like_commonjs.cts
2export = {
3    a: "This is commonjs like module"
4};
5// main.mts
6import { a } = require("./module_like_commonjs.cjs");

感想

TypeScript の module オプションと moduleResolution オプションについて、きちんと理解すれば決して難しくはない。しかしながら、体系立てて説明することが難しい。この難しさは、JavaScript 界隈の現状に合わせる形で、TypeScript のコンパイラのオプションが後付けて色々なオプションが追加されたことに起因するのかもしれない。

まぁでも体系立てて説明する必要もない。実用で困らなければ良い。そういうことにしておこう、と思いました。。。

参考

TypeScript の module オプションと moduleResolution オプションについて最も信頼できるドキュメントは、公式が提供する下記の 2 つである。

  • https://www.typescriptlang.org/docs/handbook/modules/reference.html
    • TypeScript の module,moduleResolution オプションの、より詳細なリファレンス。
    • オプションをこの設定値にするとこうなる、この設定値にすべき、が書かれている。
    • このリファレンスが最も実用的で役に立つ。
  • https://www.typescriptlang.org/docs/handbook/modules/theory.html
    • TypeScript の module,moduleResolution オプションのコンセプトについて書かれている。
    • オプションをこの設定値にしたときになぜこうなる?を理解したい場合はこの記事を読むといい。
    • あまり実用的ではない。

しかしながら、上記のドキュメントを理解するためには、いくらかの前提知識が必要となる。この前提知識の幅広さが、TypeScript の module オプションと moduleResolution オプションの理解を難しくしている(と思われる)。

  • https://ics.media/entry/16511/
    • ES Module についての理解は必須。自分は理解が浅かったため上記の記事を参考にした。
  • 良記事あれば教えてください。
    • CommonJS Module についての理解も必須。自分は CommonJS Module についてはわりと理解していたため、今回参考にした記事はない。気になる人はググってくれ。
  • https://numb86-tech.hatenablog.com/entry/2020/08/07/091142 https://zenn.dev/uhyo/articles/typescript-module-option
    • Node.js v12 以降における、CommonJS Module と ES Module の扱い方。
    • こいつが結構複雑で理解するのに苦労した。上記記事を積極的に参考にしたわけではないが、上記記事に書かれているような試行錯誤を自分もやった。
  • https://webpack.js.org/
    • バンドラーについての理解は必須ではないが、なるべく理解することをお勧めする。現在、TypeScript を用いてフロントエンドアプリケーションを作る場合において、バンドラーは切っても切れない関係である。