process.getBuiltinModule(id) は TypeScript を ESM 化させるか?
こんにちは teppeis です。普段は開発本部長をやってますが、ブログフェスに駆り出されました!
本日は Node v22.3.0 に続いて v20.16.0 にもバックポートされた process.getBuiltinModule(id)
について解説します。
問題: 同期的な条件付き require を ESM 化できない
Node v22 にて、フラグ付きで CJS (CommonJS Modules) から ESM を require
できるようになりました。いわゆる require(esm) です。これにより、今までは互換性の懸念で ESM 化を足踏みしていた著名ライブラリも ESM 化を試みる動きが出てきました。
TypeScript もその一つで、TypeScript チームは TypeScript 自体を ESM 化しようと試みました。しかしながら、今回の主題である条件付き require の ESM 化が困難という問題にぶつかりました。
TypeScript のパッケージは、Node.js でもブラウザでもユニバーサルに読み込めるようになっています。そのため、例えば sys
オブジェクトを構成する場面では、実行環境が Node.js の場合だけ fs でローカルファイルを操作する、というようなコードがあります。概念的にはこういうコードです。
// in CJS
if (isNodeLikeSystem()) {
const fs = require("fs");
// fs を使う処理...
}
こういう条件付き require を ESM 化しようと思ったら、以下のような選択肢があります。
- top-level await で dynamic import を使う
- 非同期化して dynamic import を使う
-
module.createRequire
を使う
ところが、まず top-level await は require(esm) するとエラーになってしまうので、今回の目的には使えません。次に非同期化については、既存の API を丸ごと非同期に変更することになってしまい、TypeScript エコスシテムの互換性を考えると困難な選択肢です。
最後に createRequire
ですが、一般にはこう使います。
import module from "node:module";
const require = module.createRequire(import.meta.url);
const fs = require("fs");
今回のケースでは、Node.js でもブラウザでも動くユニバーサルなコードにしたいので、そもそも 1 行目の ndoe:module
を読み込む import 宣言を書くことができません。import 宣言は if 文の中に書くことはできないので、ブラウザなど Node.js 以外の環境で実行するとエラーになります。では dynamic import で module
を作ってはどうかというと、結局また top-level await 使えない問題にぶつかってしまいます。
このように、既存の CJS を require(esm) 可能な ESM に変換する場合、同期的な条件付き require を ESM 化できないという課題として見えてきました。
process.getBuiltinModule(id)
による組み込みモジュールの同期的読み込み
解決編: そこで導入されたのが getBuiltinModule
です。この関数は Node.js の組み込みモジュールを命令的に同期的に読み込めます。これにより、ESM における同期的な条件付き読み込みを可能にします。
// in EMS
if (isNodeLikeSystem()) {
const fs = process.getBuiltinModule("fs");
// fs を使う処理...
}
またこれによって module
オブジェクトを同期的に作れるので、createRequire
を経由することで任意のモジュールを同期的に読み込めます。
if (isNodeLikeSystem()) {
const module = process.getBuiltinModule("module");
const require = module.createRequire(import.meta.url);
const foo = require("foo");
// foo を使う処理...
}
getBuiltinModule
を実装した PR を見ると、差分は非常に小さいことが分かります。元々内部的に存在していた internal な API を露出しただけだからです。また組み込みモジュールは内部的にキャッシュされている確率が高いため、同期的に読んだとしてもパフォーマンス上の課題も小さいということでしょう。
以下は導入された getBuiltinModule
を使って TypeScript の ESM 化を進めている PR です。source-map-support
を読む場面で createRequire
のイディオムも使っているようです。
まとめ
一見すると何のために存在しているのかわかりづらい process.getBuiltinModule(id)
が解決する問題を解説しました。
もし既存コードの ESM 化で似たような問題が出てきたら思い出してあげてくださいね。
Discussion