TypeScriptのmoduleオプションとmoduleResolutionオプションの使い分け

2024-06-11
TypeScript

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を用いてフロントエンドアプリケーションを作る場合において、バンドラーは切っても切れない関係である。