⚱️

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

2023/08/04に公開

皆さんこんにちは。早速ですが、TypeScriptのmoduleオプションはご存じでしょうか。moduleオプションは、例えば次のような値をサポートしています。

  • commonjs
  • umd
  • es2015
  • esnext
  • node16
  • nodenext

皆さんは、moduleオプションが何を設定するオプションなのか一言で説明できますか?

実は、TypeScriptの熟練者であってもmoduleオプションを一言で説明することは難しいはずです。なぜなら、そもそもこのmoduleオプションが複数の異なる意味で使われており、もはや一言で説明できるようなものではなくなってしまったからです。

この記事では、TypeScriptのメンテナーが書いた次のGitHub issueをベースに、moduleオプションを取り巻く状況を説明します。

https://github.com/microsoft/TypeScript/issues/55221

moduleオプションの意味とは

昔はmoduleオプションの意味は明確でした。昔というのは、moduleオプションの候補がcommonjs, amd, system, umd, es2015 くらいだった頃のことです。この頃は、moduleオプションは、TypeScriptが出力するモジュールの形式を指定するオプションでした。

つまり、TypeScriptのソースコードではimportexportを使ってモジュールを定義し、TypeScriptのコンパイラがそれをmoduleオプションで指定した形式に変換する、という仕組みでした。modulecommonjsであれば、importrequireに変換され、exportmodule.exportsに変換されます。modulees2015であれば、importimportのまま、exportexportのままになります。

ちょっと雲行きが怪しくなったのは、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: node16module: nodenextが追加されたときです。

Node.jsの特徴は、CommonJSとES Modulesの両方に対応していることです。module: node16などを指定した場合はTypeScriptもこれに準じた振る舞いをします。

  • .ctsファイルは.cjsファイルにトランスパイルされる。ES Modulesの構文で.ctsファイルを書くとCommonJSに変換される。
  • .mtsファイルは.mjsファイルにトランスパイルされる。ES Modulesの構文で.mtsファイルを書くとES Modulesのままになる。
  • .tsファイルは.jsファイルにトランスパイルされるが、どちらのモジュールシステムになるかはpackage.jsonのtypeフィールドによって決まる。

特定のモジュールシステムを指定していた従来の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はそれに合わせてチェックやトランスパイルをする、ということです。

一方で、この新しい説明は従来のオプション(特にes2022など)とはマッチしていません。従来のオプションはあくまで構文を指定するものであり、どのようなランタイムで動かすかを指定するものではないからです。そのため、es2022という明らかにES Modulesを指す値であっても、これは「ES Modulesだけをサポートするランタイムで動かす」というような意味ではありません。従来、module: es2022のコードはNode.js用だったりブラウザ用だったり、あるいはバンドラに食わせる用だったりしました。そのため、module: es2022は「どのようなシステム上でコードを動かすのか」を表現していないことになります。

ここで、moduleオプションが似て非なる2つの意味で使われることになりました。

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]

.mtsから.ctsを読み込むと?

.ctsは、TypeScriptのコードとしてはES Modulesで書けるがトランスパイル後はCommonJSになるという挙動を持ち、ファイル単体ではmodule: 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する」のと等価な機能であることを鑑みれば、妥当な挙動です。

では、これを.mtsファイルからimportしてみましょう。

// b.mts
import a from "./a.cts";

console.log(a);

これを実行すると何が表示されるでしょうか。実は、123ではありません。次の結果が表示されます。

{ foo: 3, default: 123 }

これは前述のNode.jsの挙動に準拠しています。つまり、.cjsexportsオブジェクトがCommonJSモジュールのdefault exportとして扱われます。.ctsexport defaultしたものが.mtsのdefault importにちょうど対応していないというのは奇妙ではありますが、Node.jsの仕様に準拠することを前提にすると、この挙動にするしかありません。

.ctsから.ctsを読み込んだ場合は?

ちなみに、module: node16環境において.ctsから.ctsを読み込んだ場合の挙動はmodule: commonjsの場合と同じです。

// a.cts
export const foo = 3;
export default 123;
// b.cts
import a from "./a.cts";
console.log(a);

この場合、直感通り、a123となります。b.ctsのトランスパイル結果は次のようになっており、esModuleInterOp由来のコードが見えます。

// b.cjs
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const a_cjs_1 = __importDefault(require("./a.cjs"));
console.log(a_cjs_1.default);

CJSとESMと型定義ファイル

TypeScriptの機能には、型定義ファイルがあります。特にライブラリをnpmなどで配布する場合。トランスパイルした.jsファイルと、それに対応した型定義(.d.tsなど)を配布することが多いでしょう。

まず、module: node16において、.ctsからどのような型定義ファイルが生成されるかを見てみましょう。

// a.cts
export const foo = 3;
export default 123;
// ↓↓↓ 型定義生成 ↓↓↓
// a.d.cts
export declare const foo = 3;
declare const _default: 123;
export default _default;

これを別パッケージの型定義として読み込むことを考えます。試しに、こんな感じでambient moduleとして宣言してみます。

declare module "cjs-module" {
  export const foo = 3;
  const _default: 123;
  export default _default;
}

これを.mtsからimportしてみます。

// user.mts
import a from "cjs-module";
console.log(a);
//          ^ a: 123

試してみると分かりますが、aの型は123です。つまり、プロジェクト内の.ctsをimportした場合と、ambient moduleとして定義されたモジュールをimportした場合とで、まったく同じ型定義を有するにもかかわらず、解釈が異なるということです。

ここでの根本的な問題は、CommonJSにトランスパイルされるTypeScriptプログラムであっても、それに対応する型定義はES Modulesの構文で書かれたままであり、ランタイムのモジュールシステムがどちらなのか型定義だけ見ても判別できないということです。前述のissueには次のような嘆きが書かれています。

This is perhaps, historically, because we had no idea what the actual module format of the JS file described by the declaration file is. (It would have been really nice for declaration emit to have always encoded the output module format, but here we are.)

(拙訳)これはおそらく、歴史的には、型定義ファイルが表すJSファイルの実際のモジュールフォーマットが分からないからです。(型定義を出力する際に最初からモジュールフォーマットの情報も含めておけばよかった。でも、そうはならなかった。ならなかったんだよ、ロック)

Node.jsのESMからCommonJSを読み込んだときの挙動をサポートするためには「CommonJSを読み込んだか」という判定が必要であり、その情報は現状では型定義本体に含まれていないのです。

ちゃんとpackage.jsonがある場合

では、別のライブラリの型定義を読み込む場合は問題が起きないのでしょうか。実は、ちゃんとpackage.jsonを用意すると大丈夫です。

// node_modules/cjs-module/package.json
{
  "name": "cjs-module",
  "types": "./index.d.cts"
}
// node_modules/cjs-module/index.d.cts
export declare const foo = 3;
declare const _default: 123;
export default _default;

このようにnode_modulesの中にインストールされたcjs-moduleを用意して、.ctsファイルを型定義ファイルとして指定します。これを.mtsからimportしてみます(node_modules内からモジュールを読み込むために、 moduleResolution: node16が必要です)。

// user.mts
import a from "cjs-module";
console.log(a);

こうすると、aの型は123ではなく{ foo: 3; default: 123; }となります。つまり、.mtsから.ctsを読み込んだ場合と同じ挙動になります。

要するに、module: node16が指定してあれば、たとえnode_modules内のモジュールであっても、TypeScriptは型定義ファイルが.cts.mtsか(あるいはpackage.jsonのtypeフィールド)によってランタイムがCommonJSかES Modulesなのかを判断してくれるということです。

バンドラとの関係は?

さらに頭が痛い問題は、少し前に追加されたmoduleResolution: bundlerです。これはバンドラが行うモジュール解決を再現したオプションです。大雑把な特徴としては、Node.jsと同様にpackage.jsonの機能(exportsなど)をサポートする一方、Node.jsとは異なり拡張子の省略が許されます。

一口にバンドラと言ってもさまざまなものが存在します。そして、やはり異なるバンドラは異なる挙動をするものです。特に問題となるのはこの記事ですでに説明した「ESMからCommonJSを読み込んだときの挙動」であり、Node.jsの挙動を再現しているのか、していないのかで派閥が分かれています。

現状のmoduleResolution: bundlerはNode.jsの挙動を再現しないほうのバンドラに合わせた挙動になっているため、再現するほうのバンドラ(具体的にはwebpackとesbuild)に対応できていません。この問題を扱っているのが次のissueです。

https://github.com/microsoft/TypeScript/issues/54102

記事執筆時点でのマイルストーンはTS 5.3となっていますが、難航している印象です。

型定義とエコシステムの問題

これまでに説明した通り、module: node16ではESM(.mts)からCJS(.cts)を読み込んだときの挙動をNode.jsに合わせるという挙動が実装されました。そうなると、module: node16より前に作られて公開されたたくさんのパッケージの型定義は大丈夫なのかという心配が生まれます。

実は、型定義の生成をちゃんとTypeScriptでやっていたのであれば、意外と大丈夫です。TypeScriptは互換性の維持を頑張っているので、Node.jsがESM対応する前に作られたTypeScript製のパッケージなどは大体正しく認識できます。

どちらかというと問題なのは、型定義を手で書いていたり、package.jsonの書き方を間違えたりした場合です。後者はいわゆるデュアルパッケージをやろうとして間違えると起こりがちですね。そこで、このような問題に対応するために生まれたのが、arethetypeswrongです。

https://github.com/arethetypeswrong/arethetypeswrong.github.io

このツールではnpmで公開されている型定義を検査し、問題がないか調べることができます。例えばこのツールはMasquerading as CJSという問題を検出できます。これは、ランタイムにimportで読み込まれるのはESMなのに、型定義はTypeScriptからCommonJSとして認識されるという問題です。主に、package.jsonの書き方が良くないと発生します。

他にも、この記事の話題ととくに関連しているのがIncorrect default exportという問題です。

純CommonJSのモジュールにおいて、require()の結果として得られるのは、読み込まれたモジュールのmodule.exportsです。そのため、例えばrequireの結果が関数であって欲しければ、module.exports = function() { ... }のようなコードを書くことになります。

ライブラリがもともとJavaScriptで書かれていて手書きの型定義を付け足したい場合はどうするでしょうか。まず思いつくのは次のように関数をexport defaultするような型定義ではではないでしょうか。

export default function(): void;

このようにするのは実は間違いであり、これがIncorrect default exportという問題です。というのも、CommonJSファイルに対して書かれた型定義におけるexport defaultというのは常にexports.defaultの型を定義しているのであって、exports自体の型を定義しているわけではないからです。

このミスは従来のTypeScriptでは(特にesModuleInterOpが実装されてからは)顕在化しませんでしたが、CommonJSとESMの混用を排したmodule: node16では問題になります。古いパッケージがmodule: node16で動かなくなるとしたらこのパターンが多いでしょう。

ちなみに、この場合の正しい型定義は、export =構文を用いてexports自体の型を定義するものです。

const _default: () => void;
export = _default;

.ctsとか.mtsmodule: node16以外での扱い

TypeScriptは、Node.jsの.cjs.mjsに対応するものとして.cts.mtsを導入しました。そうなると、module: node16以外のときに.cts.mtsをどう扱うかという問題が生まれます。

実は、今のところあまりうまい取り扱いにはなっていません。というのも、CJSとESMを区別する取り扱いはmodule: node16特有のものであるため、それ以外の設定ではこれらは拡張子が違うだけでただのTSファイルです。

そのため、例えばmodule: esnext下で.ctsファイルを使うと、トランスパイル結果が.cjsなのにexport構文が混ざっていたり、逆にmodule: commonjs下ではCommonJS構文で書かれた.mjsファイルが出力されたりなど望ましくない結果が現れてしまいます。

まとめ: moduleオプションの今後

ここまでの話をまとめると、module: node16およびmodule: nodenextの導入により、moduleオプションの意味が曖昧になってしまいました。module: node16はTypeScriptコード(をトランスパイルしたJS)がNode.js上で動くようにチェックするという意味で、従来のmodule: commonjsmodule: es2022などとは意味合いが異なります。

Node.js対応に伴ってCommonJSとESMの共存という概念が生まれ、それによって発生した問題もありました。具体的な問題としては、バンドラによってNode.jsの模倣度合いが違うという問題があります。このようなバンドラに対応するためには、既存のmodule: node16moduleResolution: bundlerのどちらもうまくはまらないという問題もあります。

つまり、既存のオプション体系では十分な問題解決が難しくなってきており、見直しが必要です。

冒頭で紹介したissueでは、理想的な修正としては次の内容が挙げられています。

  • moduleオプションにひとつの一貫した意味を与えるべきである。
  • module: node16module: nodenext以外のmodule.cts.mtsの扱いが良くないので、全部非推奨にする。
  • Node.jsの挙動を模倣するバンドラと模倣しないバンドラの両方に対応できるようにする。
  • (将来的には、WebブラウザなどCommonJSを全くサポートしない環境を表すmoduleオプションを作る)

つまり、node16のようにmoduleはランタイムの特性を表すという方向性にシフトしつつ、TypeScript本体がCJSとESMの区別を認識することを前提にmoduleオプションを作り直すということになります。moduleが持つ従来の選択肢は、CJSとESMの区別がない(≒ランタイムの特性を考慮に入れていない)ため非推奨になります。

issueでは、ここに書いた以外にもさまざまな可能性が挙げられており、今後議論されることになるでしょう。TypeScriptのアップデートがあった際は、この記事で得た知識を振り返ってみるとより理解が深まるかもしれません。

脚注
  1. 加えて、静的解析がうまくいけばexportsのプロパティがnamed exportとして扱われるかもしれないとされています。 ↩︎

GitHubで編集を提案

Discussion