😵

Native ESM + TypeScript 拡張子問題: 歯にものが挟まったようなスッキリしない書き流し

2022/02/12に公開
2

Node.jsのNative ESM対応は夢の機能ですが、夢を詰め込みすぎたせいかCJSからの移行を難しくしているポイントが依然として存在します。そのひとつが拡張子問題で、Node.jsのNative ESMではモジュールの拡張子を明示しなければいけなくなりました。 (これはWebブラウザの挙動に近づけるための判断だと考えられます。)

特にTypeScriptと他のツール (JestやWebpack) と組み合わせて利用している状態でのNative ESM化は実質的に未解決の状態だと言えます。本稿ではこの現状についてできる範囲で状況説明を試みます。

Node.jsの拡張子の扱い

Node.jsはCJSとESMの2つのモジュールフォーマットをサポートしていますが、これらは単にパーサーが異なるだけではなく、実質的には「2種類の異なるモジュールシステムがFFIで繋がっている」程度には隔たりがあります。

図: Node.js native ESMとCJSの相互インポート

※TypeScriptやBabelの機能でCJSに変換してから実行している場合は、見た目が import のように見えても実際は図の右側の世界 (CJS) で完結することになります。

Node.jsのNative ESMの特徴として、CJSからESMを呼び出すときは require() のかわりに import() を使う必要があるというものがあります。これはTop-level awaitと関係のある話ですが詳しくは省略します。とにかく、本節における拡張子の振舞いは「require を使うか、 import を使うか」によって決まることに留意しておくといいでしょう。

さて、Node.jsの requireimport には振舞いの違いがいくつもありますが、そのうちのひとつが拡張子の扱いです。

  • require は省略された拡張子 (.js, .cjs) を補完して読み込むことができます。
  • import は省略された拡張子を補完しません。基本的に拡張子を明示する必要があります。 (index.js や main field, pkg exports などの話は本稿では扱いません)

TypeScriptにおける拡張子の扱い

TypeScriptやJSXなどは *.js とは異なる拡張子 (.ts, .jsx, .tsx など) でソースファイルを配置し、トランスパイル時に拡張子を置き換えます。このとき、importに書くべき拡張子はトランスパイル前のファイル名に基づくべきか、トランスパイル後のファイル名に基づくべきかという問題が発生します。

図: トランスパイルと拡張子の問題

各ツールのサポート状況を考えられる前に、一般論としてどのような解決策が考えられるかを列挙してみます。

  • (A) トランスパイル前の拡張子を書く。 (import "./foo.ts";)
    • トランスパイル時に import 内の文字列を変換することを想定している。
  • (B) トランスパイル後の拡張子を書く。 (import "./foo.js";)
  • (C) 拡張子を省略する。 (import "./foo";)

CJS時代は (C) で何もせずに動いていたわけですが、ESMではそうもいかないというのがここでの問題です。

さて、ここまではTypeScriptに限らない議論でしたが、以降はTypeScriptの状況に絞って考えていきます。TypeScriptの場合、型チェッカーには公式の tsc を使うのが現時点での事実上の唯一の選択です。 (互換実装は存在するようですがここでは考えません) そのtsc はトランスパイル前の拡張子 *.ts を使うのを禁止しているので、 (A) は実質的に選択から外れることになります。

2023/01/02追記: 現在の最新版 (次の5.0バージョン?) には "moduleResolution": "bundler" オプションが追加されています。このオプションでは *.ts が許可されます。

以降の議論は記事公開時の前提 (TypeScript <= 4.9) にもとづいています。 次期TypeScriptの場合の話は最後でもう一度議論します。

そこで、Native ESM + TypeScript という状況設定では以下の2つが考えられます。

  • (B) トランスパイル後の拡張子を書く。 (import "./foo.js";)
  • (C) 拡張子を省略する。 (import "./foo";)
    • トランスパイル時に import 内の文字列を変換して拡張子を足す。

実は、TypeScriptは *.js で指定されたインポートを読み替えて *.ts ファイルとして解決することができます。そのため、ここまで出てきた登場人物 (Node.js + TypeScript) だけなら、 (B) トランスパイル後の拡張子を書く で問題なく動きます。

また、 import 内の文字列の変換について、TypeScriptコンパイラ自身は行わないことが明言されています。 (#35589, comment in #40878 などを参照) したがって (C) を行うにはBabelをサードパーティー製のプラグインと組み合わせて使うなどのアプローチが必要です。また、tscがサポートしないことから、暗黙的に (C) のアプローチは推奨されていないとも取ることができるでしょう。

これで話が終わればよいのですが、実際にはもう少し考えることがあります。それを次の節で紹介します。

ソース内モジュール解決が必要なツール群たち

(B) の方法を取る場合、トランスパイル後のソースコードは特に工夫なく動く一方、トランスパイル前のソースコードではモジュールインポートの読み替えが必要でした。

トランスパイル前のソースツリーに基づいて直接モジュール解決を行う必要があるのは、tscだけではありません。以下のようなツールも通常トランスパイル前のソースコードに対して直接適用されます。

  • Webpackなどのモジュールバンドラーは、ソースを直接読んで自らオンメモリでトランスパイラを呼ぶものが多い。
  • Jestなどのテストハーネスは開発用ツールという性質もあり、ソースに対して直接適用されることが多く、トランスパイラのサポートがあることも多い。
  • babel-nodeやts-nodeなど開発用のNode.jsラッパーはソースを直接読んでオンメモリでトランスパイルし実行する。

先にWebpackやJestなどのケースで考えてみます。これらのツールはNode.jsのCJSの模倣をするために拡張子の補完処理をする必要があります。つまり、 ./foo に対して ./foo.js を探索範囲に加えるということです。これを少し改造して拡張子の候補を増やせるようにするのは比較的簡単でしょう。

  • Node.jsのCJSの振舞い: ./foo に対して ./foo./foo.js を探索する
    • 我々が欲しい振舞い: ./foo に対して ./foo, ./foo.js, ./foo.ts を探索する

しかし、ESMの場合は話が難しくなります。

  • Node.jsのESMの振舞い: ./foo.js に対して ./foo.js を探索する
    • 我々が欲しい振舞い: ./foo.js に対して ./foo.js, ./foo.ts を探索する

いかにも作者を説得するのが面倒くさそうな内容であることが見てとれるかと思います。実際、少なくとも作者の知る範囲内ではJestとWebpackにこれらの機能は実装されていません。 (2022年2月時点)

Jest

JestはESM用の拡張子の読み替えを実装していませんが、サードパーティーのjest-ts-webcompat-resolverts-jest-resolverを設定することで対応できます。 (関連: kulshekhar/ts-jest#1057)

なおJest+TypeScriptでESMを使うには --experimental-vm-modules の有効化と extensionsToTreatAsEsm の設定が必要です。本稿の主題と直接は関係ないですが、ハマりがちなのでついでに記載しておきます。

Webpack

<del>Webpackにも *.js*.ts に読み替えるような機能はないようです。</del>

Webpack 5.74.0で、まさにこの問題に対応するための extensionAlias オプションが入りました。今後はこれで対応できるはずです。

それ以前のWebpackでは、以下の2つのアプローチが考えられます。

  • loaderで拡張子を *.js から *.ts に読み替える。
  • loaderで拡張子 .js を取り除き、拡張子の補完をWebpackに任せる。この場合、Webpackでは resolve.fullySpecified をオフにし、 resolve.extensions を設定する。

いずれもソースコード中のインポートの書き換えが必要です (具体的な方法は後述)。TypeScriptを使っていれば大抵ts-loaderかbabel-loaderのどちらかは使っているでしょうから、それに合ったプラグインを適用すればよいでしょう。

ts-node, babel-node

ts-nodeやbabel-nodeは、Node.jsにフックすることでトランスパイルが必要なソースコードを直接実行できるツールです。ビルドステップを省略できるようになるため、開発用でよく使われます。

Node.jsのCJSローダーとESMローダーは別のエコシステムだと最初に触れましたが、ts-nodeやbabel-nodeにとってもそれは同様で、これらにフックするためのAPIもCJSとESMで完全に別物です。

2022年2月時点で、ts-nodeはESMの実験的なサポートを提供しています。こちらは node --loader ts-node/esm とすることで使うことができ、拡張子も *.js と指定しておけばうまく解決してくれるようです。

一方、babel-nodeはESM向けAPIが安定化するまではESM対応を実装しない方針のようで、かわりにnode-loaderプロジェクトの@node-loader/babelを使うことができます。ただ、 @node-loader/babel は現時点では resolve hookを実装しておらず、Node.jsのデフォルトの拡張子しか使えないのでTypeScript向けでは実質使えません。 (*.js*.ts に読み替えるという話以前に、Node.js に *.ts を読み込ませることができない)

その他の戦略

(A) トランスパイル前の拡張子を書く

TypeScriptでこの方針は実質的に難しいですが、JSXなど別のトランスパイラを想定している場合はツールのサポート次第では選択肢に入るかもしれません。

この方式を取る場合は (B) とは逆に、トランスパイル後のソースツリーで正しくインポートできるために工夫が必要になります。そのためには「import宣言中の拡張子を .jsx から .js に変換する」という処理をすることになるでしょう。

2023/01/02追記: 次期TypeScript (5.0) では "moduleResolution": "bundler が使えるようになります。次の節を参照してください。

(C) 拡張子を書かない

この方法には将来性があるかはわかりませんが、現時点ではツールの組み合わせによっては逆にこちらのほうが簡単な可能性があります。特に、Node.jsで直接実行する予定がない場合 (たとえばWebpackでバンドリングし、Jestでテストするだけの場合) はこの選択肢が入ってくる可能性があります。

たとえばWebpackの場合は resolve.fullySpecified をオフにすることで今まで通り拡張子を補完してくれます。

拡張子を変換するBabelプラグイン・TypeScript Transformer

本稿ではトランスパイラで拡張子を変換するというアプローチに何度か言及しましたが、その詳細については触れていませんでした。

拡張子の書き換えには一定の需要があるようで、多くの人が似たようなプラグインを実装しています。しかしその機能や実装戦略はまちまちです。まず、拡張子の置換や削除はシンプルながら実装している場合としていない場合があります。さらに、拡張子がもともと指定されていなかったときに追加する機能にはいくつかの技術的困難があります。

  • プロジェクト外参照に拡張子を付与しない
    • たとえば、 import "react";import "react.js"; にしたくない。
    • 多くの場合は、相対パスだった場合だけ変換を適用することでこの要件を満たしている。
  • ディレクトリを正しく処理する
    • import "./foo";./foo/index.js を指している場合、 import "./foo.js"; と変換するのは誤り。
    • 実際にresolveしないと対策は難しい。
  • ファイルシステムに基づく拡張子追加
    • import "./foo"; を、実際にどんなファイルが見つかったかに応じて import "./foo.ts";import "./foo.tsx"; などに出し分けたいことがある。
    • これを実現するには実際にresolveする必要がある。

さらに、実際にresolveして拡張子を付与する実装の場合、resolveをどう実現しているかによっても期待する挙動が実現できるか変わってきます。Webpackなどでリゾルバの挙動をカスタマイズしていることが前提のプロジェクトの場合、同じカスタマイズを適用できなければ使い物にならない可能性もあります。

以上を踏まえてソースコードを読みながら軽く調査したのが以下のリストです。 (実際に動かしてはいないので、間違いがあったらすみません)

  • babel-plugin-module-extension-resolver
    • 拡張子の追加: ✔️
      • プロジェクト外参照に拡張子を付与しない: ✔️ (相対パス限定ルール)
      • ディレクトリを正しく処理できる: ✔️ (resolveするため)
      • ファイルシステムに基づく拡張子追加: ✔️ (resolveするため)
      • 指定した拡張子の付与: ✔️ (extensionsToKeep)
    • 拡張子の置換: ✔️ (元のファイル名でresolveできる場合に限る)
    • 拡張子の削除: ×
    • resolver実装: 独自
  • babel-plugin-extension-resolver
    • 拡張子の追加: ✔️
      • プロジェクト外参照に拡張子を付与しない: ✔️ (相対パス限定ルール)
      • ディレクトリを正しく処理できる: ✔️ (resolveするため)
      • ファイルシステムに基づく拡張子追加: ✔️ (resolveするため)
      • 指定した拡張子の付与: ×
    • 拡張子の置換: ×
    • 拡張子の削除: ×
    • resolver実装: resolve package
  • typescript-transform-extensions
  • babel-plugin-replace-import-extension
    • 拡張子の追加: ✔️
      • プロジェクト外参照に拡張子を付与しない: ✔️ (相対パス限定ルール)
      • ディレクトリを正しく処理できる: ×
      • ファイルシステムに基づく拡張子追加: ×
      • 指定した拡張子の付与: ✔️
    • 拡張子の置換: ✔️
    • 拡張子の削除: ✔️
    • resolver実装: なし (静的に判定)
    • 静的に処理するもののなかでは一番よくできてそうな印象。
  • babel-plugin-replace-import-extensions
    • 名前に反して、単にimport pathに正規表現置換を適用するだけのプラグイン。
    • 拡張子の追加: ✔️
      • プロジェクト外参照に拡張子を付与しない: △ (相対パス限定ルールを自力で設定すれば何とかなる)
      • ディレクトリを正しく処理できる: ×
      • ファイルシステムに基づく拡張子追加: ×
      • 指定した拡張子の付与: ✔️
    • 拡張子の置換: ✔️
    • 拡張子の削除: ✔️
    • resolver実装: なし (静的に判定)
  • babel-plugin-add-import-extension
    • 拡張子の追加: ✔️
      • プロジェクト外参照に拡張子を付与しない: ✔️ (相対パス限定ルール)
      • ディレクトリを正しく処理できる: ✔️ (resolveしてディレクトリを検出している)
      • ファイルシステムに基づく拡張子追加: ×
      • 指定した拡張子の付与: ✔️
    • 拡張子の置換: ✔️
    • 拡張子の削除: ×
    • resolver実装: 独自 (ディレクトリ検出用にのみ使用)
      • require.resolve を使っているように見えるが、実際には到達不能コードになっている
  • babel-plugin-transform-require-extensions
    • 拡張子の追加: △
      • プロジェクト外参照に拡張子を付与しない: ×
      • ディレクトリを正しく処理できる: ×
      • ファイルシステムに基づく拡張子追加: ×
      • 指定した拡張子の付与: ✔️
    • 拡張子の置換: ✔️
    • 拡張子の削除: ✔️
    • resolver実装: なし (静的に判定)
  • @zoltu/typescript-transformer-append-js-extension (GitHub)
    • 拡張子の追加: ✔️
      • プロジェクト外参照に拡張子を付与しない: ✔️ (相対パス限定ルール)
      • ディレクトリを正しく処理できる: ✔️ (resolveしてディレクトリを検出している)
      • ファイルシステムに基づく拡張子追加: ×
      • 指定した拡張子の付与: △ (.js のみ)
    • 拡張子の置換: ×
    • 拡張子の削除: ×
    • resolver実装: なし (静的に判定)
  • @build-script/typescript-transformer-append-js-extension
    • 拡張子の追加: ✔️
      • プロジェクト外参照に拡張子を付与しない: ✔️ (相対パス限定ルール)
      • ディレクトリを正しく処理できる: ✔️ (resolveしている)
      • ファイルシステムに基づく拡張子追加: ×
      • 指定した拡張子の付与: △ (.js のみ)
    • 拡張子の置換: ×
    • 拡張子の削除: ×
    • resolver実装: 独自

*.ts 拡張子をそのまま書く方針が使えるようになった

2023/01/02追記

--moduleResolution bundler (formerly known as hybrid) というPRがマージされました。これはTypeScript 5.0に入る予定です。

これにより *.ts 拡張子をそのまま書く方針が使える可能性があります。

(余裕があったら使える条件について書く)

Discussion

yumyum

こちら大変助かりました🙇🏻‍♂️
resolver入れてjest.config.jsの設定を行ったらテスト通るようになりました。

ありがとうございます🙏

segayuusegayuu

typescriptでmoduleResolution:"node16"設定を行った場合は本文(B)で強制されます。
また、ts-loaderやesbuild(esbuild-loader)でもtscに寄せることが下記issueで決定済みです。
https://github.com/TypeStrong/ts-loader/issues/1110
https://github.com/evanw/esbuild/issues/1343

本文(B)に寄せた設計でwebpackを使用した際、本文(A,C)に寄せた挙動になるため、齟齬が発生しエラーが発生します。
そのため、本文(B)の挙動に寄せるプラグインが用意されています。
https://github.com/anthonynichols/typescript-transform-extensions
また、webpack@5.74.0ではプラグインなしで本文(B)の挙動にすることができるようになりました。
https://github.com/webpack/webpack/pull/16001