🛠️

TypeScriptファイルのモジュール拡張子を補完するツール

2023/04/18に公開

ツールの内容

例えば以下のようなファイル構成のTypeScriptプロジェクトがあるとします。

root
├── foo.ts
├── bar.ts
└── cool
    ├── index.ts
    └── cool.ts

各ファイルの中身は以下の通りです。

  • foo.ts

    foo.ts
    import { bar } from './bar'
    import { cool } from './cool'
    console.log(bar)
    console.log(cool)
    
  • bar.ts

    bar.ts
    export const bar = 42
    
  • cool/index.ts

    cool/index.ts
    export { cool } from './cool'
    
  • cool/cool.ts

    cool/cool.ts
    export const cool = 'this is cool'
    

これが本稿で紹介するツールによって変換を行うと以下のようになります。

  • foo.ts

    foo.ts
    import { bar } from './bar.ts'
    import { cool } from './cool/index.ts'
    console.log(bar)
    console.log(cool)
    
  • cool/index.ts

    cool/index.ts
    export { cool } from './cool.ts'
    

ツール作成の背景

複雑化するTypeScriptベースの開発環境

今日のNode.jsとTypeScript、そしてCommonJS(以下CJS)とESModules(以下ESM)の
モジュールシステムがどちらも環境に存在しうるために混沌 複雑化するpackage.jsonや、
各種開発ツールチェインの対応状況(例えばJestのESM対応のつらさ)などを見ると
シュッと開発環境を整えるのにもなかなか苦労することはないでしょうか。

Node.jsプロジェクトにおいて、依存ライブラリの保守性や開発効率化を意識した
monorepo構成なども、ツールチェインの勃興を見るにトレンドとしてあるように見えます。
そうなるとNode.jsのモジュール解決プロセスが悪さをして意図しない依存解決をしてしまったりなども悩ましいです。

https://www.jonathancreamer.com/inside-the-pain-of-monorepos-and-hoisting/

Denoの存在

筆者としては、DenoのNode.js互換が進んだ暁に、仕事で運用しているフロントエンドプロジェクトをまるっとDenoに移行したい、そんな気持ちがあります。
そんなことやって怖くないの、みたいな疑問もあるかもしれませんが、例としてNext.jsのようなフレームワークをサーバーサイドも含めて運用する(用はサーバーサイドレンダリング)
する様な場合は、ランタイムでNode.js互換コードが動く必要があり、こちらはかなり注意深い検証と対応が必要かと思います。

しかしながら、開発ツールチェインなどをNode.js互換で運用し、
アプリケーションはブラウザのみで動くような場合は、思い切ってDenoによる開発環境に移行しても問題ないと考えています。

実際のところ、下記のドキュメントを見る限りはもう少し時間がかかりそうなイメージです。
https://github.com/denoland/deno/blob/db39855fcb9e90131432d1c03bd5c16263addb3e/ext/node/polyfills/README.md
筆者が2023年始頃に試したときは、Storybookが依存するworker_threadsなどが未対応で実行できず、みたいな感じでした。

ランタイム毎のモジュールシステム

Denoのモジュールシステムは、ESM のみが存在しています。
ESMに関して、厳密には処理系で事情が異なることに関して下記リンクなどを参考にしていただければです。

https://hiroppy.me/blog/node-esm

https://deno.land/manual@v1.17.0/npm_nodejs/compatibility_mode

楽観的ではありますが、将来的にDeno移行を目論見つつ、現在できることは
Node.js & TypeScriptプロジェクトのESM化をやり遂げておくと速やかに移行に入れると考えました。

また、CJSベースのプロジェクトで依存するライブラリがESMのみで配布される場合、
ViteなどのCJSをESMへとモジュールの変換を行うバンドラーを介するような場面を除き、
CJSからESM製のライブラリを利用するにはimport()による非同期なimportしか出来ないというのも気がかりです。
こうなると、プロジェクトの都合上そのライブラリが最後にCJSとして配布していたバージョンから更新することが出来ないということも起こり得るということになります。
逆にプロジェクトがESMベースであればCJSモジュール・ライブラリのimportには特に問題なく機能します。

faux ESM

Denoの実行環境におけるTypeScriptファイルは、上記で示したようなモジュール文字列部の拡張子の省略が出来ません。

foo.ts
import { bar } from './bar'
                     ^^^^^
...

この文字列部のことをECMAScriptではModuleSpecifierと命名されているようです。
以降こちらはローカルモジュールの拡張子、とします。
余談ですが、Node.jsではこれをimport Specifiersと命名されているとのことです。

https://sosukesuzuki.dev/posts/import-specifiers/

そして、世の中のnpmライブラリでない、Webサービス等で運用されているような
Node.js & TypeScriptプロジェクトでは現状、おおよそがCJSベースで構成されていると思われます。
コードの大部分はESMっぽい構文(Fake ESMあるいはfaux ESMと呼ばれているようです。)が用いられており、
これは上記のようなローカルモジュールの拡張子を省略した構文になっているはずです。

このあたりの話、およびESM移行については下記の記事にて詳しく書かれているので
ぜひ参考ください。

https://en-jp.wantedly.com/companies/wantedly/post_articles/410531

TypeScript 4.7の登場

そしてTypeScriptのESMサポートとして、TypeScript 4.7で
--moduleResolution node16
または
--moduleResolution nodenext
が追加されました。

https://devblogs.microsoft.com/typescript/announcing-typescript-4-7/#esm-nodejs

これを有効にした際、下記のような記述によってTypeScriptをNode.jsのESMとして動作させることが可能になります。

foo.ts
// 実際に存在するファイルはbar.ts
import { bar } from './bar.js'
...

乱暴に「ESMとして動作させることが可能」と書きましたが、
実際にNode.jsのモジュールシステムを判定する条件はpackage.jsonの記述であったり、
ファイルの拡張子であったりともう少し複雑なので、これに関しては下記の
ESM_FILE_FORMATの項目や、上記「Announcing TypeScript 4.7」の記事を参照ください。
https://nodejs.org/api/esm.html#resolution-algorithm

しかし、こうして.jsとして拡張子を記述させるとバンドラーなどのツール側で
これを実ファイルの.tsとして解釈するなどの対応が別途必要になったという経緯があったようです。

https://zenn.dev/qnighy/articles/19603f11d5f264

そんなTypeScriptが5.0になり、この状況も変わってくるということで次に移ります。

TypeScript 5.0の登場

新しく追加された設定項目として
--moduleResolution bundler
および
--allowImportingTsExtensions
が追加されていることに着目します。

https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/#moduleresolution-bundler

この2つを有効にすることにより、Node.js & TypeScriptをESMとして、なおかつ
実ソースファイルへのローカルモジュールの拡張子を記述した状態でプロジェクトを
構築することが出来るということになります。
しかし、仕事で関わるようなプロジェクトだとファイル数が膨大で、これを手で逐一書き換えていくのはなかなか難しいと思います。

そこで本ツールを作成するに至りました。

ツールの使い方

ここでようやく本稿で紹介するツール、module-specifier-resolverの使い方です。
ユースケースはNode.jsプロジェクトを想定していますが、実装はDeno製です。

https://github.com/Hajime-san/module-specifier-resolver

こちらは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_//という文字列が存在する場合、それらは取り除かれてしまいます。
  • ソースファイルのシングルクオーテーション、ダブルクオーテーション、セミコロン、カンマ、インデントは変換の際に失われてしまいます。

実際に変換を実行する

以下はローカルに本ツールのリポジトリがある状態で、Vite1パッケージ
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を下記のように変更します。

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です。

その際に、以下のノードが見つかれば変換の対象としています。

おおまかな処理フロー

  • ファイルを読み取る
  • ファイルの文中改行文字を\n//_PRESERVE_NEWLINE_//\nで置き換える
  • ts.preProcessFileの戻り値であるimportedFilesを得る
    この値は文字列の配列で、以下の場合に処理を続行
    • 配列の長さが1以上である
    • 1個以上の値が存在し、それがnode_modulesなどの外部参照でない
      かつ
      その文字列が拡張子がresolveされたファイルパスの拡張子と異なる
  • 現在処理中のファイルの絶対パスとimportedFilesfileName(絶対パス)を突き合わせて相対パスに変換する
  • 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として出力してからテストを行う例が紹介されています。

https://playwright.dev/docs/test-typescript

現状、本ツールでローカルモジュールの拡張子の補完を.tsで行うとtscによるビルドが出来なくなり、この対応も取れなくなるので留意ください。

おわりに

モジュールシステムやTypeScript周りに関して、その複雑性や歴史的経緯にも触れる必要があり、少し外部資料が多くなってしまいました。

Deno移行と言ってもNode.jsがこれまでに築いたエコシステム、ライブラリやコミュニティとは
引き続き共存しうるものだと思っています。
技術面でいうなればWebAssembly(WASI)にコアロジックを寄せる手法は今後発展の余地がありそうですし、DenoとNode.js両対応のライブラリを配信するようなツールもあるので、今後どうなっていくのか楽しみですね。

https://qiita.com/kt3k/items/ec54346e9516b29291e8
https://miyauchi.dev/ja/posts/dts-deno-module/

Discussion