読む:TypeScriptのmoduleオプションの話、あるいはTypeScript開発者の苦悩、あるいはCJSとESMの話

ここでmjsとかnodeとかeslintとかで躓いてる。構成ファイルとかjsの歴史系の深い知識無いから勉強せべば
ということでこれ読む

ほーTSのmoduleオプションというものに関する記事らしい。そもそもそれが初耳だ
TSを全然知らん

?
昔はmoduleオプションの意味は明確でした。昔というのは、moduleオプションの候補がcommonjs, amd, system, umd, es2015 くらいだった頃のことです。この頃は、moduleオプションは、TypeScriptが出力するモジュールの形式を指定するオプションでした。
はーなるほど、「moduleとはコンパイル結果の形式だった」ってことか
つまり、TypeScriptのソースコードではimportやexportを使ってモジュールを定義し、TypeScriptのコンパイラがそれをmoduleオプションで指定した形式に変換する、という仕組みでした。moduleがcommonjsであれば、importはrequireに変換され、exportはmodule.exportsに変換されます。moduleがes2015であれば、importはimportのまま、exportはexportのままになります。

なるほど
ちょっと雲行きが怪しくなったのは、module: es2022が追加されたときです。ES2022の新機能の一つに、top-level awaitがあります。これはモジュールのトップレベル(他の関数の中ではない部分)にawaitを書けるようにする機能です。TypeScriptコードでtop-level awaitを使うためには、moduleオプションをes2022に設定する必要があります(Node.jsでもtop-level awaitがサポートされているため、node16などに設定しても良いです)。
module: es2015などの設定の場合は、top-level awaitを使うとコンパイルエラーになります。なぜなら、top-level awaitをES2015やCommonJSなどのモジュールシステムに翻訳することができないからです。
ここで、moduleオプションは、ただ単に翻訳先のモジュールを指定するものではなくなりました。加えて、TypeScriptコードで何を書いていいのかを制御する役割も持つことになりました。
翻訳先のモジュールを指定するという意味合いは依然あるが、それに加えて、新しい機能が出るにつれて生じる互換性の問題つまり何を使えるかという「制約」が乗っかってきたのか
ほう
とはいえ、この段階ではまだmoduleオプションはシンプルに理解することができます。翻訳しろと言われてもできないものはできないのだから、その時はコンパイルエラーにするしかありません。

聞いたことないモジュール名
node16系オプションの登場
状況が大きく変わったのは、module: node16とmodule: nodenextが追加されたときです。

へぇ、node凄いな
Node.jsの特徴は、CommonJSとES Modulesの両方に対応していることです。
じゃああの問題はnodeに原因は無いのかも
だって.mjsというESMをNodeは読み取れるはずだしな、対応しているのだから
だが対応しているだけで「読み取るタイミングが遅い」みたいな可能性もあるのでそこは留意か

ほー、moduleをnode16としか指定してないからトランスパイル後の形式がcjsになったりesmになったりと柔軟性が出てくるわけか。抽象性が上がったというか。
module: node16
などを指定した場合はTypeScriptもこれに準じた振る舞いをします。
.cts
ファイルは.cjs
ファイルにトランスパイルされる。ES Modulesの構文で.cts
ファイルを書くとCommonJSに変換される。.mts
ファイルは.mjs
ファイルにトランスパイルされる。ES Modulesの構文で.mts
ファイルを書くとES Modulesのままになる。.ts
ファイルは.js
ファイルにトランスパイルされるが、どちらのモジュールシステムになるかはpackage.jsonのtype
フィールドによって決まる。
ctsはcjsのts版、mtsはmjsのts版っぽい

そういう意味で「Node.jsに準拠というか抽象化」が起こったということか、なるほど
特定のモジュールシステムを指定していた従来のmoduleオプションとは異なり、node16系オプションは「Node.jsに準拠」という一段階抽象化された意味を持っています。その実は、拡張子を見たり必要に応じてpackage.jsonを見に行ったりといった複雑な要件を含んでいます。

ぬぬ
このようなmoduleオプションの新しい意味づけを、冒頭のissueでは次のように表現しています。
A declarative description of the module system that will process your emitted code at bundle-time or runtime
(拙訳)ランタイムに(またはバンドル時に)コードを処理するモジュールシステムを宣言するもの
自分で訳してみる
生成されたコードをランタイムに(またはバンドル時に)処理する、モジュールシステムの宣言的記述
全然わからん

ふむ
つまり、例えばnode16であれば、TypeScriptのコンパイラに伝えるのは「このコードはNode.jsで動かす」ということであり、TypeScriptはそれに合わせてチェックやトランスパイルをする、ということです。
まぁでもそれは、宣言しているという意味ではずっと同じな気がするなぁ
「このコードはES2015形式にコンパイルするよ」が「このコードはNode.jsで動くよ、しかもトランスパイルしたりpackage.json見に行ったりもするよ」というちょっと違う話になるっていうことだろうか
あーやっぱりそうっぽい。なるほど~
一方で、この新しい説明は従来のオプション(特にes2022など)とはマッチしていません。従来のオプションはあくまで構文を指定するものであり、どのようなランタイムで動かすかを指定するものではないからです。そのため、es2022という明らかにES Modulesを指す値であっても、これは「ES Modulesだけをサポートするランタイムで動かす」というような意味ではありません。従来、module: es2022のコードはNode.js用だったりブラウザ用だったり、あるいはバンドラに食わせる用だったりしました。そのため、module: es2022は「どのようなシステム上でコードを動かすのか」を表現していないことになります。
ここで、moduleオプションが似て非なる2つの意味で使われることになりました。
以下2つの意味が混在して使われてた感じか
- どういう形式にコンパイルするか
- どのランタイム上で実行するか

たしかに、前述の通りNodeだから当然cjsもesmもサポートしているということ
module: node16であらわになったCJSとESMの問題
module: node16は、CJSとESMの両方を同時にサポートするシステムです。
へぇなるほど
実は、従来は両者の違いをそこまで真剣に取り扱う必要がありませんでした。いつぞやにTypeScriptにesModuleInteropが実装されて以降は、CommonJSとES Modulesの違いは大体うまく吸収されるため、細かいことを考えなくてもおおよそ何とかなったのです。ランタイムの側も、webpackをはじめとするバンドラがうまくやってくれていたため、CommonJSとES Modulesの違いを意識する必要はあまりありませんでした。
ほー、例を見ていく
しかし、Node.jsのモジュールシステムに正確に対応するためには、そのような雑な対応がまかり通らなくなってきました。

はーなるほど、確かにそれは問題が起こりそうだ
例えば、Node.jsではCommonJSモジュールからES Modulesをrequireすることができません。TypeScriptもこの判定をサポートしています。.ctsファイルから.mtsファイルをimportしようとすると次のようなコンパイルエラーになります。
The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("./mts.mjs")' call instead.
逆に、ES ModulesからCommonJSモジュールをimportすることはできます。この場合、CommonJS側のmodule.exportsがdefault exportと見なされます[1]。
あーでも、コンパイルエラーになってしまうということならさっきの記述(以下)の部分と変わらない気もしてきたなぁ
とはいえ、この段階ではまだmoduleオプションはシンプルに理解することができます。翻訳しろと言われてもできないものはできないのだから、その時はコンパイルエラーにするしかありません。
大きな問題なのだろうか?まぁシンプルに理解できるというだけで問題ではあるし、今回のケースはNode.jsという第三者も考慮しないといけないからいろいろ複雑になっちゃうよ、みたいなことなのかも?

ぬぬ、.cts
はcjs(系)のくせにesmでも書けるのか、Nodeみたいに柔軟でややこしいな...
.cts
は、TypeScriptのコードとしてはES Modulesで書けるがトランスパイル後はCommonJSになるという挙動を持ち、

ふむふむ、まぁ普通に妥当だな
// a.cts
export const foo = 3;
export default 123;
// ↓↓↓ トランスパイル ↓↓↓
// a.cjs
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.foo = void 0;
exports.foo = 3;
exports.default = 123;
defaultエクスポートに着目すると、exports.defaultとしてエクスポートされていることが分かります。これは、ES Modulesのdefaultエクスポートがもともと「defaultという名前でnamed exportする」のと等価な機能であることを鑑みれば、妥当な挙動です。
esmで書いてもファイルは.cts
なんだから.cjs
にトランスパイルされる訳で、cjsということはexport default
ではなくexports.default
という書き方になるよね、という話

では、これを.mtsファイルからimportしてみましょう。
// b.mts
import a from "./a.cts";
console.log(a);
これを実行すると何が表示されるでしょうか。
普通に123ではないだろうか
...ぬぬ??なんでnamed exportも含め全部オブジェクトとして持ってくるんだろう!?
実は、123ではありません。次の結果が表示されます。
{ foo: 3, default: 123 }

んー、さっきの文を読み返してみたら引っかかる箇所があるな
これは、ES Modulesのdefaultエクスポートがもともと「defaultという名前でnamed exportする」のと等価な機能であることを鑑みれば、妥当な挙動です。
あ、そういうことだったんだ!シンプルにそれは知らなかった
自分なりに意訳してみるとこんな感じかなぁ↓
export defaultは裏側でdefaultという名前が付けられてる、んで単なるnamed exportとして一旦扱われることに注意。まぁ最終的には勿論ちゃんとexport defaultになるけど。
あーそれを踏まえるとさっきの謎もわかったかも
つまりcjsの中にesmを書いたけどそれをesmから読み込むときはesmとして認識できず当然cjsとして認識しちゃうから、export default 123
が勝手にexport const default = 123
みたいな感じに変換されちゃう、つまり「defaultという名前で」認識しちゃったということではなかろうか
だから「裏側でdefaultという名前が付けられてるだけであって、これはdefault exportであることに注意せねば!」という自己認識みたいなやつはそのファイル自体が.mjs/.mtsでなければ出来ないということなのかも
つまりこういうまとめになるかな
- ctsからcjsへのトランスパイルでは、cts内の謎なesmもちゃんとesmとして認識してくれる
- だがmjsからctsを読み込む場合そんなことはできず、cts内のesmをcjsとして認識しちゃう
...んん?いやcjsとして認識してしまうというより、「本当はdefault exportなんだけど、一旦defaultという名前でnamed exportしたものとして扱うね」という中間表現のまま認識が終了してしまう
という表現が正しい気がしてきたなぁ
以下の部分が中間表現的なニュアンスなのかどうかわからないのでちょっと未知数だけど
これは、ES Modulesのdefaultエクスポートがもともと「defaultという名前でnamed exportする」のと等価な機能であることを鑑みれば、妥当な挙動です。
そう考えないと、「default exportはもともとnamed exportやで」の意味が想像できないのでとりあえず中間表現みたいなものだと理解しておく

答え合わせ
これは前述のNode.jsの挙動に準拠しています。つまり、.cjsのexportsオブジェクトがCommonJSモジュールのdefault exportとして扱われます。.ctsでexport defaultしたものが.mtsのdefault importにちょうど対応していないというのは奇妙ではありますが、Node.jsの仕様に準拠することを前提にすると、この挙動にするしかありません。
ん?あぁ.ctsではなくコンパイル後の.cjsを.mtsからインポートしたってことだったのか、そもそも前提を見誤っていた
んんん?いやでもそうなるとexports.defaultがdefaultエクスポートであるということを認識できていないのは謎では?
.cjs内でのexports.defaultは.cjsの掟で考えればdefaultエクスポートであるというのはわかるはずだが
でも.mtsからインポートするときはそんなことも考慮してくれないってことなのかなぁ
「俺は.mtsだ。俺(.mts)からインポートしたんだからimport先のものも全部mts/mjs形式で認識するぞー」というジャイアン的発想なのかも?
それなら理屈は通る

だがそれでも更に微妙な謎はあって、mtsでexports.defaultという文字列を認識したとしたらそれはもはやmtsの記法ではない謎の文字列なんだからエラーが出るはず
でもエラーは出ない
ということはmtsから見るとexports.defaultは「何かよくわからんけどまぁdefaultって名前で普通にnamed exportしてんじゃね?とりあえずそう理解しとくわー」っていう謎すぎる雑すぎる帰着が起きてるということなのかもしれない
うーん、いろいろ謎だ、よくわからん
...まぁでも、記事内で「奇妙」って言われてるのはまさにそういうことかも
.cts
でexport default
したものが.mts
のdefault importにちょうど対応していないというのは奇妙ではありますが、Node.jsの仕様に準拠することを前提にすると、この挙動にするしかありません。
まぁNodeが関わってることで奇妙なことが起きてるっていう浅い理解でとりあえずは良さそうかな

というか勘違いしてたかも
ESMのexport defaultはCJSのmodule.exportsか
当然、CJSのexports.defaultはdefaultというnamed exportなだけという話やん、なんか勘違いすごい

ここからいつか読む。一旦終わり