mizdra's blog

ぽよぐらみんぐ

TypeScript 4.5 Beta で実装された Node.js ESM 対応を試してみた

ご存じの方もいるかもしれませんが、TypeScript 4.5 Beta で遂に Node.js ESM がサポートされました。まだ Stable に来ていない実験的な機能なのですが、どういうものなのか気になったので、先日趣味で作っているプロダクトに導入してみました。で、この記事はその備忘録です。実験的な機能で、これから状況もどんどん変わっていくので、数カ月後にはこの記事の内容も古くなっているかもしれません。未来から来た人がこの記事を読んでいる場合は、注意して読んで下さい。

今回 TypeScript の Node.js ESM 対応を導入してみたのはこちらの eslint-interactive というプロダクトです。以前このブログでも紹介した ESLint の API を使った CLI ツールです。

github.com

www.mizdra.net

www.mizdra.net

どうせ物好きな人しか読まないと思うので、雑にサクサク書いていきます。

最初に結論

どういう手順で進めて、どんな障害があって、それをどうやって解決していったかは全部 PR に詳しく書いたので、これ見てもらえれば大体分かります。

github.com

という訳で、以下はその PR の補足です。PR で書ききれなかったこととか、面白かった部分について書いていきます。

まずガイドを読む

JavaScript エコシステムを取り巻くモジュールシステムの事情にもそれなりに知っておかないと、かなり進めるのが難しいです (というか知っていてもムズいです)。手を加えなければならない場所も多岐にわたり、作業量自体も大きいです。そこで、まずは手を動かす前に参考になる資料を読んで、どういったことをしていくことになりそうか、目処をつけることにしました。

ありがたいことに @teppeis さんが TypeScript の Node.js ESM 対応についてまとめた非常に素晴らしい記事を書かれています。まずはこれを読みましょう。

zenn.dev

もし ESM 化するプロジェクトがライブラリの場合、sindresorhus 氏 *1 が書かれている記事も読むと良いです。ライブラリを ESM 化するとユーザ側にどんな影響があるかがまとまっています。

toolchain のサポート状況を確認する

Node.js と TypeScript が ESM をサポートしていても、Jest などの toolchain がサポートしていなくて ESM 化できない、という可能性もあるので、 toolchain の対応状況は調べておきます。大体 toolchain ごとに公式ドキュメントがあったり、 ESM 用のトラッキング Issue があるので、それを読んだり、トラッキング Issue にリンクされている Issue や PR のタイトルを見て気になるものがあったらクリックして、我々の作業のブロッカーになりそうな話題がないか確認していきます。

Jest のサポートは絶賛進行中で、モジュールの mock 周りのサポートが結構欠けているので気をつけて下さい。jest.mockjest.requireActual はまだ機能しません。 結構使われている機能なので、ここがブロッカーになることが多いと思います。eslint-interactive でも使っていましたが、別に mock 使わなくても良い箇所だったので、mock を使わない形に書き換えて乗り切ることにしました。

モジュールファイルを動的に import する機構を持つライブラリに気をつける

これはプロダクトによるのですが、「モジュールファイルを動的に import するような機構を持つライブラリを使っている場合、そのモジュールファイルを ESM で書けるようライブラリがサポートしているか」がブロッカーになることがあります。ライブラリが CJS なモジュールファイルしかサポートしていない場合、モジュールファイルを ESM で書くことができません。

例えば、eslint-interactive では ESLint が開発者向けに提供している API を使い、ESLint に自作のカスタムルールを動的に import させていました。ESLint のドキュメントを読む限りはカスタムルールは CJS でしか書けなさそうだったので、仕方なく当初はカスタムルールは CJS、それ以外は ESM で書く、という戦略で ESM 化を進めようと考えていました。dynamic import を使うと CJS から ESM を import できるので、これで部分的に CJS を使い、コードベースの他では ESM を使う、というハイブリットな構成を実現できます。

// src/rules/custom-rule.cts
import { type Rule } from 'eslint';

const rule: Rule.RuleModule = {
  create(context: Rule.RuleContext) {
    // ESM なモジュールを dynamic import で import する
    // 本当は import statement (`import ... from '...';`) で import したいけど、CJS => ESM だと
    // できなくて、dynamic import が唯一の import 方法。
    const { foo } = await import('./util.mts');

    // foo など ESM で書かれたモジュールの utility を使ってルールを実装
  },
}

普通はこれで問題ないのですが、ESLint のカスタムルールでは、ESLint 側の実装上の都合により、カスタムルールを非同期で記述できないことが分かりました。つまり dynamic import は使えません。CJS から ESM を読み込むには、dynamic import が唯一の方法なので、カスタムルールから事実上 ESM を読み込めないということを意味します。詰んだ…

仕方がないので eslint/eslint に issue を書いて、ESM のカスタムルールサポートして欲しいと Feature Request を送りました。

github.com

実装の許可が貰えれば実装するつもりでいたのですが、実は (.eslintrc.js とか一般ユーザからはまだ利用できないけど) ESLint が開発者向けに提供している API ならもう ESM なカスタムプラグイン/ルール読み込めるよ (ref1, ref2, ref3)、と教えてもらえたので、それを使う形で乗り切ることにしました。聞いてみてよかった。

pkg.bin で参照している実行可能ファイルには拡張子が必須

eslint-interactive は CLI ツールです。npm i @mizdra/eslint-interactive すると eslint-interactive というコマンドがインストールされ、このコマンドを介してツールを利用できるようになります。このコマンドのインストールには package.jsonbin というフィールド (以下 pkg.bin) を使っています。

numb86-tech.hatenablog.com

当初、eslint-interactive の package.json では、以下のように pkg.bin を記述し、bin/eslint-interactive にリンクさせていました。

  "bin": {
    "eslint-interactive": "bin/eslint-interactive"
  },

bin/eslint-interactive は shebang を書いて node コマンドで実行するようにシステムに指示していました。拡張子がないのですが、中に書いてあるのは JavaScript (厳密に言うと shebang はまだ ECMAScript 的に valid ではないですが…) なので、これで問題なく実行できます。

#!/usr/bin/env -S node -r source-map-support/register

import { run } from '../dist/index.js';

run({
  argv: process.argv,
}).catch((error) => {
  process.exitCode = 1;
  console.error(error);
});

で、本題なのですが、ESM 化するとこれ壊れます。厳密に言うと、package.json"type": "module" が書かれた状態で、bin/eslint-interactive を実行すると Unknown file extension "" という実行時エラーが出ます。

$ bin/eslint-interactive
TypeError: Unknown file extension "" for /Users/mizdra/src/github.com/mizdra/eslint-interactive/bin/eslint-interactive
    at new NodeError (node:internal/errors:371:5)
    at Object.file: (node:internal/modules/esm/get_format:72:15)
    at defaultGetFormat (node:internal/modules/esm/get_format:85:38)
    at defaultLoad (node:internal/modules/esm/load:22:14)
    at ESMLoader.load (node:internal/modules/esm/loader:353:26)
    at ESMLoader.moduleProvider (node:internal/modules/esm/loader:274:58)
    at new ModuleJob (node:internal/modules/esm/module_job:66:26)
    at ESMLoader.#createModuleJob (node:internal/modules/esm/loader:291:17)
    at ESMLoader.getModuleJob (node:internal/modules/esm/loader:255:34)
    at async Promise.all (index 0)

多分 Node.js が拡張子を元に ESM として扱うか CJS として扱うかを決定しているので、拡張子がないとそこがうまく動かないんでしょうね。node --eval "..." で文字列で JS コードを渡したときも、拡張子がないので似たような問題が発生します。

何か良い回避策無いかなあと Node.js のドキュメントを探してみたところ、--input-type=module というオプションがあることに気づきました。どうやらこれを使うと node --eval "..." で渡した文字列を ESM として処理するか CJS として処理するか、Node.js ランタイムに指示できるらしいです。

// from: https://nodejs.org/api/packages.html#--input-type-flag
node --input-type=module --eval "import { sep } from 'path'; console.log(sep);"

これ pkg.bin にも使えないかな? と思って試してみたんですが、駄目でした。--eval オプション OR --print オプションとの併用が必須らしいです。

github.com

打つ手がなく途方に暮れていたところ、id:rinsuki さんに拡張子を付けてみてはどうか、と教えていただきました。試しに .jsを つけてみたところ、なんと無事回避できました。

github.com

勘の良い人ならコマンドのインストール時に拡張子が外れた状態で node_modules/.bin に symlink が置かれるので、動かないのでは? と思うかもしれませんが、意外と何事もなく動きました。気づけば簡単だったけど、気づくまで結構時間掛かった… 何はともあれ id:rinsuki さんありがとうございました!

特定のディレクトリ配下のファイルを全部 CJS にする

eslint-interactive ではテストで使う fixtures があって、fixtures/ ディレクトリに配置しています。この中に .js を含む静的リソースを入れているのですが、プロジェクトのルートの package.json"type": "module" を加えたせいで、fixtures/ 配下の *.js も ESM として扱われるようになってしまいました。eslin-interactive のテストの都合上、fixtures/ 配下は CJS である必要があったので、これによってテストが壊れてしまいました。

今回は { "type": "commonjs" } とだけ書かれた package.jsonfixtures/package.json に配置して回避しました。これで fixtures/ 配下の *.js が CJS として扱われます。ESM 化する上で便利なテクなので覚えておくと良いです。

github.com

Jest が pkg.exports を解決できるようにする

Jest はまだ pkg.exports を解決してくれません (pkg.exports って何? という方はこちらを参照)。

github.com

一部のパッケージは pkg.exports を使って ESM モジュールを export しているので、これを解決できないと、パッケージを ESM で利用することができません。sindresorhus 氏のパッケージもこれを使っています。

Jest は割とモジュールの resolution をユーザ側で制御できるので、色々な workaround があります。例えば以下は moduleNameMapper を使って手動で解決する workaround です。こことかこことかここ とか見ながら合わせていきます。職人技です。

github.com

とはいえ #ansi-styles とかが chalk 以外で使われていると普通に壊れるので、オススメしません。オススメはカスタムリゾルバによる workaround です。どれくらいちゃんと動くものなのか分かりませんが、id:mizdra が確認した限りでは期待通りに動いてそうです。eslint-interactive の ESM 化でも最終的にこれを使いました。Jest の公式のサポートが来るまでのつなぎなので、一旦雑に回避しました。

github.com

github.com

package/path/to/module.mjs 形式の import がうまく動かない

3rd-party パッケージの中には、パッケージ名だけでなくパスまで指定させて import してもらうタイプのモジュールを提供しているものがあります。例えば comlink とかです。

import { Worker } from 'worker_threads';
import { wrap } from 'comlink';
// `package/path/to/module.mjs` 形式で import
import nodeEndpoint from 'comlink/esm/node-adapter.mjs';

const worker = new Worker('./worker.js');
const remoteFn = wrap(nodeEndpoint(worker));
console.log(remoteFn());

tsconfig.json"module": "node12" にすると、この形式で import するとモジュールに対応する型定義が見つからないとコンパイルエラーになることがあります。

$ tsc -p tsconfig.src.json --noEmit
src/index.ts:5:26 - error TS7016: Could not find a declaration file for module 'comlink/dist/esm/node-adapter.mjs'. '/Users/mizdra/src/github.com/mizdra/eslint-interactive/node_modules/comlink/dist/esm/node-adapter.mjs' implicitly has an 'any' type.
  If the 'comlink' package actually exposes this module, try adding a new declaration (.d.ts) file containing `declare module 'comlink/dist/esm/node-adapter.mjs';`

5 import nodeEndpoint from 'comlink/dist/esm/node-adapter.mjs';
                           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

comlink には node_modules/comlink/dist/esm/node-adapter.d.ts にちゃんと型定義があるのですが、tsc がこれをうまく見てくれません。これは node-adapter.mjs に対応する型定義が node-adapter.d.mts であるためです。*.d.ts ではなく *.d.mts です。試しに *.d.mts を用意してみると型検査に pass します。

$ cp node_modules/comlink/dist/esm/node-adapter.d.{ts,mts}
$ tsc -p tsconfig.src.json --noEmit && echo passed
passed

仕方がないので eslint-interactive では tsconfig.jsonpaths オプションを使い、TypeScript の世界でのみ comlink/dist/esm/node-adapter.mjs を無理やり comlink/dist/esm/node-adapter.js にマップさせ、comlink/dist/esm/node-adapter.d.ts の型定義を使わせる、ということをしています。

github.com

けど comlink/dist/esm/node-adapter.d.ts は CJS な型定義ファイルなので、default の挙動がおかしくなります。TypeScript の型上では nodeEndpoint.default に default export されたものが詰め込まれているように見えるのですが、ランタイム上では nodeEndpoint に詰め込まれています。どうしようもないので as any してます。難しい。

github.com

所感

各ツールチェインで ESM のサポートが進みつつあるとはいえ、まだまだ workaround を駆使しないと難しい、というのが所感です。comlink のように 3rd-party の型定義が合わない問題も結構悩ましいです。こういう形式でモジュールを export しているパッケージは沢山あるので、至るところで同様の問題に遭遇しそうです。TypeScript の Node.js ESM が Stable に来たタイミングで、皆で協力して型定義を直していく、みたいな感じになるのかな?

Pure ESM なパッケージを自然に import できるようになったりといったメリットがあるので、業務のプロダクトでも入れられると良いかなと思っていたのですが、実際に ESM 対応をしてみて、業務のプロダクトへの導入はまだ先になるかなと思いました。eslint-interactive は最小限のツールチェインしか使っていなかったので、何とか ESM 化できましたが、業務のプロダクトのような大規模のものになると、ここで紹介していない問題も色々出てきそうな予感がします。webpack とか babel の ESM のサポート状況とかも関わってきそうです。とはいえまだ実験的な機能ですからね。導入できる日が来るのを楽しみに待ちましょう。

あと今回の作業で気づいたこととかあれば、適時ツールチェイン側に issue を立てたりしようと思います。ちゃんとコミュニティにもフィードバックしていきたい。それに同じことをしようとした人がいた時に、参考にしてもらえますしね。

*1:sindresorhus 氏ってどんな人? という方はこちら: https://github.com/sponsors/sindresorhus

ポケットモンスター・ポケモン・Pokémon・は任天堂・クリーチャーズ・ゲームフリークの登録商標です.

当ブログは @mizdra 個人により運営されており, 株式会社ポケモン及びその関連会社とは一切関係ありません.