TypeScriptファイルのモジュール拡張子を補完するツール
ツールの内容
例えば以下のようなファイル構成のTypeScriptプロジェクトがあるとします。
root
├── foo.ts
├── bar.ts
└── cool
├── index.ts
└── cool.ts
各ファイルの中身は以下の通りです。
-
foo.ts
foo.tsimport { bar } from './bar' import { cool } from './cool' console.log(bar) console.log(cool)
-
bar.ts
bar.tsexport const bar = 42
-
cool/index.ts
cool/index.tsexport { cool } from './cool'
-
cool/cool.ts
cool/cool.tsexport const cool = 'this is cool'
これが本稿で紹介するツールによって変換を行うと以下のようになります。
-
foo.ts
foo.tsimport { bar } from './bar.ts' import { cool } from './cool/index.ts' console.log(bar) console.log(cool)
-
cool/index.ts
cool/index.tsexport { cool } from './cool.ts'
ツール作成の背景
複雑化するTypeScriptベースの開発環境
今日のNode.jsとTypeScript、そしてCommonJS(以下CJS)とESModules(以下ESM)の
モジュールシステムがどちらも環境に存在しうるために混沌 複雑化するpackage.jsonや、
各種開発ツールチェインの対応状況(例えばJestのESM対応のつらさ)などを見ると
シュッと開発環境を整えるのにもなかなか苦労することはないでしょうか。
Node.jsプロジェクトにおいて、依存ライブラリの保守性や開発効率化を意識した
monorepo構成なども、ツールチェインの勃興を見るにトレンドとしてあるように見えます。
そうなるとNode.jsのモジュール解決プロセスが悪さをして意図しない依存解決をしてしまったりなども悩ましいです。
Denoの存在
筆者としては、DenoのNode.js互換が進んだ暁に、仕事で運用しているフロントエンドプロジェクトをまるっとDenoに移行したい、そんな気持ちがあります。
そんなことやって怖くないの、みたいな疑問もあるかもしれませんが、例としてNext.jsのようなフレームワークをサーバーサイドも含めて運用する(用はサーバーサイドレンダリング)
する様な場合は、ランタイムでNode.js互換コードが動く必要があり、こちらはかなり注意深い検証と対応が必要かと思います。
しかしながら、開発ツールチェインなどをNode.js互換で運用し、
アプリケーションはブラウザのみで動くような場合は、思い切ってDenoによる開発環境に移行しても問題ないと考えています。
実際のところ、下記のドキュメントを見る限りはもう少し時間がかかりそうなイメージです。Storybookが依存するworker_threadsなどが未対応で実行できず、みたいな感じでした。
筆者が2023年始頃に試したときは、ランタイム毎のモジュールシステム
Denoのモジュールシステムは、ESM のみが存在しています。
ESMに関して、厳密には処理系で事情が異なることに関して下記リンクなどを参考にしていただければです。
楽観的ではありますが、将来的にDeno移行を目論見つつ、現在できることは
Node.js & TypeScriptプロジェクトのESM化をやり遂げておくと速やかに移行に入れると考えました。
また、CJSベースのプロジェクトで依存するライブラリがESMのみで配布される場合、
ViteなどのCJSをESMへとモジュールの変換を行うバンドラーを介するような場面を除き、
CJSからESM製のライブラリを利用するにはimport()
による非同期なimportしか出来ないというのも気がかりです。
こうなると、プロジェクトの都合上そのライブラリが最後にCJSとして配布していたバージョンから更新することが出来ないということも起こり得るということになります。
逆にプロジェクトがESMベースであればCJSモジュール・ライブラリのimportには特に問題なく機能します。
faux ESM
Denoの実行環境におけるTypeScriptファイルは、上記で示したようなモジュール文字列部の拡張子の省略が出来ません。
import { bar } from './bar'
^^^^^
...
この文字列部のことをECMAScriptではModuleSpecifierと命名されているようです。
以降こちらはローカルモジュールの拡張子
、とします。
余談ですが、Node.jsではこれをimport Specifiersと命名されているとのことです。
そして、世の中のnpmライブラリでない、Webサービス等で運用されているような
Node.js & TypeScriptプロジェクトでは現状、おおよそがCJSベースで構成されていると思われます。
コードの大部分はESMっぽい構文(Fake ESMあるいはfaux ESMと呼ばれているようです。)が用いられており、
これは上記のようなローカルモジュールの拡張子
を省略した構文になっているはずです。
このあたりの話、およびESM移行については下記の記事にて詳しく書かれているので
ぜひ参考ください。
TypeScript 4.7の登場
そしてTypeScriptのESMサポートとして、TypeScript 4.7で
--moduleResolution node16
または
--moduleResolution nodenext
が追加されました。
これを有効にした際、下記のような記述によってTypeScriptをNode.jsのESMとして動作させることが可能になります。
// 実際に存在するファイルはbar.ts
import { bar } from './bar.js'
...
乱暴に「ESMとして動作させることが可能」と書きましたが、
実際にNode.jsのモジュールシステムを判定する条件はpackage.json
の記述であったり、
ファイルの拡張子であったりともう少し複雑なので、これに関しては下記の
ESM_FILE_FORMAT
の項目や、上記「Announcing TypeScript 4.7」の記事を参照ください。
しかし、こうして.js
として拡張子を記述させるとバンドラーなどのツール側で
これを実ファイルの.ts
として解釈するなどの対応が別途必要になったという経緯があったようです。
そんなTypeScriptが5.0になり、この状況も変わってくるということで次に移ります。
TypeScript 5.0の登場
新しく追加された設定項目として
--moduleResolution bundler
および
--allowImportingTsExtensions
が追加されていることに着目します。
この2つを有効にすることにより、Node.js & TypeScriptをESMとして、なおかつ
実ソースファイルへのローカルモジュールの拡張子
を記述した状態でプロジェクトを
構築することが出来るということになります。
しかし、仕事で関わるようなプロジェクトだとファイル数が膨大で、これを手で逐一書き換えていくのはなかなか難しいと思います。
そこで本ツールを作成するに至りました。
ツールの使い方
ここでようやく本稿で紹介するツール、module-specifier-resolver
の使い方です。
ユースケースはNode.jsプロジェクトを想定していますが、実装はDeno製です。
こちらはdeno.landにホストしており、https://deno.land/x/module_specifier_resolver@v${VERSION}/bin.ts
のファイルを実行することとなります。
故に利用しているマシンにDenoがインストールされていることが必要です。
当スクリプトにはdry runモードを用意しているので、自プロジェクトで実行する場合、
まずはこちらを試すことをお勧めします。
以下は各種の実行権限を付与したdry runモードの実行例です。
${VERSION}
の部分は適宜読み替えてください。
実行するDenoのバージョンによっては--unstable
フラグが必要になります。
deno run --allow-env --allow-read --allow-write https://deno.land/x/module_specifier_resolver@v${VERSION}/bin.ts -b=./src -c=./tsconfig.json -d
引数の内容は以下になります。
key | description | type | default |
---|---|---|---|
-b | ベースディレクトリ 省略した場合は実行ディレクトリが基準。 |
string |
. |
-c |
tsconfig.json のパス省略した場合は実行ディレクトリが基準 |
string |
./tsconfig.json |
-d | dry runモード 省略可 |
boolean |
false |
-r | ファイル書き換え前に (yes/no)の入力を受け付ける 省略可 |
boolean |
false |
dry run実行後はスクリプトの実行ディレクトリにmodule-specifier-resolver.log
という
ログファイルが作成されるので、こちらの内容を見て、意図する変換が行われるかどうか確認してください。
ツールの制限事項
まず、CJSのrequire
をESMのimport
に置き換えるという処理は行われないので注意ください。
以下、制限事項になります。
- TypeScriptの
compilerOptions
の項目であるpaths
のマッピングによるローカルモジュールの拡張子
は補完しません。 - 本ツールではソースファイルの文中改行を維持するために、
\n//_PRESERVE_NEWLINE_//\n
というテキストでファイル読み込み時に改行文字を置換します。最終的にこの文字をテキストから取り除く際に、この意図以外の部分で//_PRESERVE_NEWLINE_//
という文字列が存在する場合、それらは取り除かれてしまいます。 - ソースファイルのシングルクオーテーション、ダブルクオーテーション、セミコロン、カンマ、インデントは変換の際に失われてしまいます。
実際に変換を実行する
以下はローカルに本ツールのリポジトリがある状態で、Vite
の1パッケージの
src
ディレクトリに対して変換を実行した際のターミナルの出力です。
引数の-r
を追加することで、実行前に変換されるファイル数が表示されます。
この後、y
キーを押すことで変換処理が開始されます。要は安全装置みたいなものです。
$ deno run --allow-env --allow-read --allow-write https://deno.land/x/module_specifier_resolver@v${VERSION}/bin.ts -b=./examples/vite/packages/vite/src -c=./examples/vite/packages/vite/tsconfig.json -r
processing...
transform target 97 files found.
Are you sure complement the extension of module specifier to files? (y/n)
変換内容をチェックする
変換処理を実行後、プロジェクトにTypeScriptが5.0以降がインストールされた状態で
tsconfig.json
を下記のように変更します。
{
"compilerOptions": {
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
...
},
...
}
この状態でnpx tsc --noEmit
による型チェック、およびプロジェクトで利用している
バンドラーによるビルドコマンドの実行、あるいは他ツールチェインのコマンドの実行が
正しく行われるかを確認してください。
問題なければ、この変換処理を実行後は上記のツールの制限事項で挙げた通り、
スタイルが崩れた状態になっていると思われますので、プロジェクトで運用しているPrettierなどのコードフォーマッターで整形をかけるのを推奨します。
ユースケース
筆者のように、Deno移行を見据える方、あるいは拡張子が省かれているのが
なんとなく変な感じがするという方や、もしくはTypeScript 4.7 ~ 4.9で
--moduleResolution nodenext|node16
の項目でESM対応した際に、.js
へと
ローカルモジュールの拡張子
を変更したけれど、自然な拡張子にしたいという方などが
対象になるかと思います。
--moduleResolution bundler
を選択する時点で選択肢からはおおよそ外れ得ることになりますが、allowImportingTsExtensions
は--noEmit
もしくは--emitDeclarationOnly
の追記も
必要になるのでtsc
によるビルドができなくなります。
サンプルとしては多くないものの、筆者がある程度変換を試した限りでは概ね問題ないけれど、一部コメントの行がズレるというのがありました。
開発与太話
変換の対象
変換処理が実行する際、ソースファイルをTypeScriptとしてパースしたAST(抽象構文木)を
走査します。利用したのはtscのAPIです。
その際に、以下のノードが見つかれば変換の対象としています。
-
CallExpression
かつ
ImportCall
いわゆるdynamic import
による関数呼び出しです。 -
ExportDeclaration
export
宣言。意外と見落としがちです。 -
ImportDeclaration
import
宣言。
おおまかな処理フロー
- ファイルを読み取る
- ファイルの文中改行文字を
\n//_PRESERVE_NEWLINE_//\n
で置き換える -
ts.preProcessFileの戻り値である
importedFiles
を得る
この値は文字列の配列で、以下の場合に処理を続行- 配列の長さが1以上である
- 1個以上の値が存在し、それが
node_modules
などの外部参照でない
かつ
その文字列が拡張子がresolveされたファイルパスの拡張子と異なる
- 現在処理中のファイルの絶対パスと
importedFiles
のfileName
(絶対パス)を突き合わせて相対パスに変換する - ASTを走査し、変換の対象ノードを見つけてノードを更新する
- ノードから文字列へと変換し、
\n//_PRESERVE_NEWLINE_//\n
を取り除く
改行回りで色々やっているのは、ts.Printer.printNodeはどうも
オリジナルソースファイルの改行を保持しないっぽいという事情故になります。
Node.js ESMにおける周辺ツールチェイン雑感
JestのESMが難しいと冒頭に書きましたが、端的に言うとTypeScriptのトランスパイル、CJSとESMの相互変換、モジュール解決を楽に行うことが出来るツール、あるいはそのツール自体を介在することが可能なツールであれば、現状のNode.js ESM & TypeScriptのエコシステムでも
太刀打ち出来るように感じます。
例を上げるとバンドラーであるViteとテストランナーのVitestの組み合わせは強力です。
(サラッと書きましたがこれをやるには相当の腕力が必要と思います。)
逆に、そういったツールを介在しないものは独自のモジュール解決を実装しない限り、
Node.js ESM & TypeScriptのルールに従わざるを得ないため、なかなか苦労します。
PlaywrightのESM対応はまさにそれで、テストファイルが依存しているモジュールは
-
ローカルモジュールの拡張子
の記述が必要 - 外部ライブラリもESMで配布されている必要がある
など、なかなか難しいです。
この場合、下記のドキュメントではCJSとして出力してからテストを行う例が紹介されています。
現状、本ツールでローカルモジュールの拡張子
の補完を.ts
で行うとtscによるビルドが出来なくなり、この対応も取れなくなるので留意ください。
おわりに
モジュールシステムやTypeScript周りに関して、その複雑性や歴史的経緯にも触れる必要があり、少し外部資料が多くなってしまいました。
Deno移行と言ってもNode.jsがこれまでに築いたエコシステム、ライブラリやコミュニティとは
引き続き共存しうるものだと思っています。
技術面でいうなればWebAssembly(WASI)にコアロジックを寄せる手法は今後発展の余地がありそうですし、DenoとNode.js両対応のライブラリを配信するようなツールもあるので、今後どうなっていくのか楽しみですね。
Discussion