2021年10月10日日曜日

最近のTypeScriptのES Modules対応事情

コロナの影響で中止となった幻のTSConf 2020で、TypeScriptとES Modulesについて登壇する予定でした。

最近のTypeScriptは、モジュール関連で新たな仕様が出てきたようなので簡単にまとめておきます。前職同僚でNode.js Core Collaboratorのshisamaおよびdeno-ja Slackコミュニティーからの情報を勝手に集約しました。みなさんありがとうございます。

背景

JavaScript同様、TypeScriptでもimport構文(ES Modules)をサポートしています。しかし、ES ModulesではCommonJS形式のrequire()と異なり拡張子を省略できないという制約があります。

フロントエンド開発では、ほとんどの場合でwebpackncc、フレームワーク標準のモジュールバンドラー等を使って1つのファイルにまとめると思うのでこのあたりはあまり気にする必要はないのですが、バックエンド開発に使う場合はまとめずに使う場合もあるかと思います。

また、DenoはTypeScriptのコードを変換せずにそのまま実行するという事情もあり、必ずしもJSに変換して1つにまとめることが得策とは限りません。特にライブラリー等を開発している場合はまとめた時点で型情報が消えてしまうので。

問題点とTypeScriptのポリシー

ご存知の通り、TypeScriptはJavaScriptのスーパーセットという位置づけなので、JavaScriptで有効なコードはtscで変換しないという固いポリシーがあります。

「背景」で書いたような状況では、例えば

import foo from "./foo";

というTypeScriptのソースコードをtscにかけたときに

import foo from "./foo.js";

のように変換してくれれば割とすんなり解決するんですが、TypeScriptのポリシーによりこういった処理は行いません。

実際、今まで何度かIssueが作られたことがあるのですが、そのたびに拒絶されています。

そんな状態なので、TypeScriptでES Modules対応のコードを書こうとすると

のどちらかの方法を使う必要があります。

どちらもスマートな方法とは言えませんし、後者はNode.jsだけで動かす場合はともかくDenoにも対応したい場合は使えません。

TypeScript 4.5

そして、TypeScript 4.5でモジュール周りの新たな仕様が出てきました。

Announcing TypeScript 4.5 Beta

ざっくり言うと、

  • 新たに.cts, .mtsという拡張子を提供する
  • 上記の拡張子を持つTypeScriptファイルは、それぞれCommonJS形式、ES Modules形式にトランスパイルされることを明示する

というものです。.cjsはCommonJS形式を明示するための新しい拡張子です。

ただ、これはこれで結局ファイルシステム上に存在しないファイルを指定する必要があることは変わりませんし、そもそもこれは「問題点とTypeScriptのポリシー」で挙げた問題を解決するための仕様ではありません。

将来的な話

こちらのIssueで、将来的に"module": "deno"をサポートする可能性について触れられています。ただ、これで何がどのように解決されるのかはよく理解できていません。

結局どうすればいいの

「JavaScriptで有効なコードは変換しない」というTypeScriptのポリシーと最近のJavaScript/TypeScript界隈の仕様が色々と不整合を起こしてきているのが諸々の混乱の種です。

おそらく一番スマートな(そして一番実現可能性の低い)解決方法は、TypeScriptがポリシーを一部変更して「import構文で対象のモジュールの拡張子が.tsの場合はtsconfig.json"module"の値に応じて拡張子を変換する」機能が実装されることかなと思っています。つまり、

import foo from "./foo.ts";

というコードがあったとして、"module": "commonjs"が指定されていたらこれまで通り

const foo = require("./foo");

に変換し、"module": "esnext"が指定されていたら

import foo from "./foo.js";

に変換し、拡張子.mjsが必要なら

import foo from "./foo.mjs";

に変換し、"module": "deno"が指定されていたら

import foo from "./foo.ts";

のままにするといった具合です。

ただしこれは上でも書いたとおりTypeScriptのポリシーにそぐわないため、まず実現されないと考えていいと思います。

でもなぁ・・・そもそもの話import foo from "./foo.ts";はJavaScriptのコードとして(文法的には通るけど、意味的に)機能不全を起こしている上に、現時点のTypeScriptではimport対象のファイルに.ts拡張子を指定できない(TS2691エラー)ので、.ts拡張子をエラーにせずに拡張子変換するような機能を実装してくれてもよさそうなのになぁ・・・と思ってしまいます。まあ文法的に通るというところがTypeScriptのポリシーに反しているということだと思いますが。

結局、TypeScriptのモジュールの新たな仕様は上記の問題を解決するためのものではないので、ライブラリー開発などでES Modulesに対応しようと思ったら、上で書いたとおり

のどちらかを使うしかなく、CommonJSやDenoなどの複数の環境に対応したい場合は前者を使うしかないという状況は変わらないのかなぁという結論です。

なお、上記のBabelプラグインやコマンドラインツールはどちらもワシが作ったので何か機能要望等があればできる限り対応しますよ。

2 件のコメント:

  1. この拡張子の問題ですが、
    TypeScript/JavaScript 双方の世界でエイリアスを定義し、拡張子(というかファイルパス)を隠ぺいしてしまう、という解決方法があります。

    TypeScript では TSConfig の "compilerOptions.paths" を利用
    https://www.typescriptlang.org/ja/tsconfig#paths

    Node.js では package.json の "imports" フィールドを利用
    https://nodejs.org/dist/latest-v14.x/docs/api/packages.html#packages_imports

    手前味噌となり恐縮ですが、
    先日公開した npm package においてこの仕組みを利用していますので、ご参考になれば幸いです。
    https://github.com/nujarum/mklnks
    開発時に `src/cli.ts` => `#main` (== `src/main.ts`)
    であった依存関係が、
    実行時は `bin/cli.mjs` => `#main` (== `dist/main.mjs)
    に解決されます(配布物は minify しているので見づらいですが)。

    さて、では Browser の世界はどうかと言うと、
    `Import Maps` という仕様の策定が進められていて、Chromium 系ブラウザではすでに利用可能となっています。
    https://github.com/WICG/import-maps
    https://caniuse.com/import-maps


    以上、既知の内容でしたらご容赦くださいませ。

    返信削除
    返信
    1. コメントありがとうございます!
      モジュールバンドラーで1つのファイルに統合する前提であれば、拡張子はあまり問題にならないと思っています。

      統合せずにモジュールのまま実行したい場合に問題になりそうですね。
      Node.jsでバックエンド開発していてデバッグの都合上1ファイルにまとめたくない場合や、本記事の例のようにDenoでも使えるモジュールを作りたい場合なんかが当てはまるとおもいます!

      削除