🚎

TypeScript 4.5 以降で ESM 対応はどうなるのか?

2021/10/24に公開

先日リリースされた TypeScript 4.5 Beta で、待望の Node.js ESM 対応がアナウンスされました。

https://devblogs.microsoft.com/typescript/announcing-typescript-4-5-beta/

その後、ユーザーからのフィードバックを経て、TypeScript チームは TS 4.5(11/16 リリース予定)では ESM 対応を stable リリースせず、Nightly のまま継続検討することを決定しました[1]

https://github.com/microsoft/TypeScript/issues/46453

今後どうなるかは分かりませんが、この記事では現時点で提案されている TypeScript の ESM 対応について解説し、従来の ESM 対応の問題点をどう解決するのか、今後の課題は何か、などについて現状確認していきます。内容としては動向を追っかけたいライブラリやフレームワークの作者、物好きな人や向けなので、一般の TS ユーザーは状況が固まってベストプラクティスや便利ツールが出てきたあとでキャッチアップするのが良いかと思います。

先に結論

  • 新たに導入されたオプション module:node12 または module:nodenext によって、従来 TypeScript で Native ESM を書こうとすると発生していた問題点が一通り解決され、TypeScript を使っても Pure ESM なパッケージを素直に書けるようになる。
    • package.json が type:module の場合は .ts, .js ファイルを ESM として扱える
    • TS で拡張子を .mts, .cts にすることで、.mjs, .cjs なファイルを出力できる
    • allowJs.mjs.cjs も読める
    • package.json の exports/imports フィールド を解決できる
    • パス解決のルールが Node.js ESM 互換になる(拡張子を自動付与しない、など)
    • Dynamic Import を適切に扱える
  • TypeScript で ESM から CJS に変換済みの擬似 ESM パッケージを読む場合に、default export を読み込めない問題は解決していない
  • 周辺エコシステムのツール群は未対応の状況。Node.js や TS 側の仕様検討も含めて今後もしばらく開発が継続する。

用語

  • CommonJS (CJS): 従来式の Node.js CommonJS で書かれたファイルまたはパッケージ
  • ES Modules (ESM): ES2015 で定義されたモジュール仕様。Node.js では v12 以降でネイティブにサポートされている。
  • Native ESM: ESM 形式で記述されたファイルを、Node.js またはブラウザで直接 ESM として実行する方式またはそのファイル。擬似 ESM と区別するために Native と付けて呼ぶ。
  • 擬似 ESM: ESM 形式で記述されたファイルを、実行前に TypeScript や Babel で CJS に変換する方式またはそのファイル。ランタイムでは、Node.js は変換後のファイルを CJS として実行する。faux-ESM に対する私の訳語。
  • Pure ESM: Native ESM だけで構成する npm パッケージ。CJS からは require() で読むことはできず Dynamic Import import() を使って非同期に読む必要がある。

Native ESM や Pure ESM という言葉はよく見かけますが、使われ方は広いのでこの記事では上記のような意味で使います。

これまでの問題とその背景

混み入っているので、以下のスライドを見て下さい。発表動画はコチラの 1:26:56 から

https://www.slideshare.net/teppeis/nodejs-esm-final-season

ざっくり言うと、このスライド P22 の問題が一通り解決されます。

TypeScript で Native ESM を出力したい - 発表スライド P22

これに加えて、Dynamic Import で Native ESM を読めなかった問題も解決されます(後述)。

そして今後も解決されないのは、このスライド P24 の問題です。

TypeScript Modules を import 不可能 - 発表スライド P24

では詳細を見ていきます。

新しい TypeScript での Native ESM の扱い方(module:node12, module:nodenext

tsconfig.json の compilerOptionsmodule:node12 を指定すると、TypeScript がモジュールを Node.js v12 (Native に ESM を扱えるようになったバージョン)相当として扱う新しいモードになります。top-level await を使いたい場合は moduel:nodenext を指定します。

{
  "compilerOptions": {
    "module": "node12"
  }
}

Node.js 相当のモジュール解釈になるということは、package.json の type 属性や exports, imports 属性に従うということ。なので、変換後に .js になる .ts ファイルは、type:module を指定すると Native ESM として扱われます。同様に .mjs を出力する .mts.cjs を出力する .cts が新たに扱えるようになりました。

注意点としては、moduleResolution: node を同時に指定すると正しく動きません。従来モードになって CJS への変換が走ります。moduleResolution は無指定、もしくは module と合わせて node12 または nodenext を指定する必要があります。

Native ESM の書き方としては、これまでの擬似 ESM と大きく変わりませんが、重要な違いは import 文の識別子の仕様が Node.js の ESM に準拠するということです。識別子では拡張子を省略できず、.js, .mjs など出力後の拡張子を明示的に指定します。ここで指定する拡張子は .ts ではないことに注意 してください。また、識別子にディレクトリを指定して index.ts を読む、などの CJS 時代の指定方法も使えません。

例として、package.json で type:module を指定した場合は以下のように書きます。

// src/foo.ts
export function helper() {
  // ...
}

// src/bar.ts
import { helper } from "./foo.js"; // `.ts` ではないことに注意
helper();

TS による変換結果は以下。import/export 文の中身は変わりません。

// dist/foo.js
export function helper() {
  // ...
}

// dist/bar.js
import { helper } from "./foo.js"; // `.ts` ではないことに注意
helper();

type:commonjs の場合、ESM は .mts で書きます。TS が変換後に出力するファイルの拡張子は .mjs になるので、import 文の識別子には .mjs を指定します。

// src/foo.mts
export function helper() {
  // ...
}

// src/bar.mts
import { helper } from "./foo.mjs"; // `.mts` には `.mjs` を指定
helper();

識別子周りの議論

import 文の識別子に .js, .mjs を指定するのは、変換前の時点では存在しないファイルを指定する感じになるので最初は違和感があるかもしれません。とはいえ、将来的には VS Code や ESLint ルール等が整備されていけば人間がこれを意識することも少なくなり、違和感は無くなると個人的には考えています。

VS Code では、import 文で識別子 .js を付与するオプション "typescript.preferences.importModuleSpecifierEnding": "js" は従来から存在します[2]。ただし、VS Code (というより TypeScript Language Service か) は現時点では module:node12 に未対応な点が多く、tsc コマンドでは通る正しい構文なのに、VS Code ではエラーとして警告されたり、補完が期待通り動かないことが多々あるようです。また、tsc も ESM 周りのエラーが全体的に不親切で、初めて Native ESM を触ろうとする人がハマりそうな印象です。

TS 4.5 の stable に ESM が入らないことになった理由として、その辺りのバグや UX 上の懸念点が短期間で解消困難であることが挙げられています。

https://github.com/microsoft/TypeScript/issues/46452

import 文の識別子に関する仕様はかなり議論がありましたが、 JS のセマンティクスを TS が変更しないという TypeScript チームの強いポリシーに基づいて現在の仕様になっています[3]。個人的には、Node.js やブラウザで動く JS を TS で書くというマジョリティのユースケースを満たすので方針としては問題ないと思っています。ただ、それ以外の周辺エコシステム(ts-node、各種バンドラ、Deno 等)と親和性でいくつか問題があり、その辺りは今後の検討課題であることが前述の issue に挙げられています。

CJS interop

ESM と CJS の相互互換性 (interoperability, interop) についてです。

ESM から CJS を読む

ESM から CJS を読む場合、普通に import 文を識別子付きで書けば、Node.js の interop 機能によって CSJ を読み込めます。

// src/foo.cts
export function helper() {
  // ...
}

// src/bar.mts
import { helper } from "./foo.cjs"; // ここは変換後も変わらない
helper();

変換結果は以下になります。

// dist/foo.cjs
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.helper = void 0;
function helper() {}
exports.helper = helper;

// dist/bar.mjs
import { helper } from "./foo.cjs"; // ここは変換後も変わらない
helper();

もし諸般の事情により ESM から require を使いたい場合は、いにしえの import/require 構文を使います。

// src/bar.mts
import foo = require("./foo.cjs");
foo.helper();

コンパイルすると、Node.js で ESM から require() を使うための createRequire を使ったイディオムに変換されます。

// dist/bar.mjs
import { createRequire as _createRequire } from "module";
const __require = _createRequire(import.meta.url);
const foo = __require("./cjs2.cjs");
foo.helper();

CJS から ESM を読む

CJS から Native ESM を読むには、Dynamic Import を使うのが Node.js における唯一の方法です。

// src/foo.mts
export function helper() {
  // ...
}

// src/bar.cts
(async () => {
  const { helper } = await import("./foo.mjs");
  helper();
})();

TypeScript による変換後も、CJS の中の Dynamic Import は期待通りそのまま残っています。

// dist/foo.mjs
export function helper() {
  // ...
}

// dist/bar.cjs
(async () => {
  const { helper } = await import("./foo.mjs");
  helper();
})();

従来の TypeScript では module:commonjs のとき Dynamic Import が require() に変換されてしまい Native ESM を読めないという問題がありましたが (issue#43329)、module:node12 では Dynamic Import が変換されず残るため、CJS から Native ESM を無事に読むことができるようになりました。

残り続ける問題: default export の互換性

前述のスライドでも述べていますが、端的に言えば先行した TypeScript の esModuleInterop の仕様(=擬似 ESM の仕様、Babel 等も同様)と、後発の Node.js ESM の CJS interop で default export の仕様が異なり、相互互換性がないという問題です。

例えば、以下のように default export するコードを TypeScript で CJS に変換したもの(= 擬似 ESM)を publish した npm パッケージ foo を想定します(今までは普通にこう書いてましたよね)。

// package `foo`
export default () => console.log("foo!");

擬似 ESM の世界(これまでの TypeScript, Babel の世界)ではもちろんこう import できますが、

import foo from "foo";
foo();

これを Native ESM から import する場合、以下のように書かないと実行できません。

import foo from "foo";
foo.default();

Dynamic Import する場合はもっとおかしな雰囲気になります。

const foo = await import("foo");
foo.default.default();

TypeScript で Native ESM を書いた場合にも、Native なので同様の問題が発生します。加えて、型定義ファイルが同梱されている場合は非常に大きな問題があり、上記のような work around を書くと型エラーになってしまいます。つまり、動作するコードを書くと型エラーになり、型に合わせると動作しないコードになります。パッケージに同梱された型定義は外部から修正したり無効にしたりできないので、詰みます。

直接利用するライブラリだと比較的すぐ気づけますが、孫依存のライブラリで発生したりするとデバッグにも相当苦労します[4]

例えば、default export を避ける

npm パッケージ作者としては、Native ESM からも import してもらうためにパッケージをメジャーアップデートをして Pure ESM 化をすると、今度は従来の TypeScript 擬似 ESM からは読めなくなってしまいます。TypeScript の新しい Native ESM はまだしばらく過渡期が続くと想定され、従来方式を完全に切り捨てるのはなかなか難しい判断です。

そもそもの問題は、default export の非互換性です。このような面倒を避けるため、TypeScript で npm パッケージを作る場合、エンドポイントとしては default export ではなく named export を使うのが無難と言えるでしょう。

無理矢理、両対応 (従来方式)

どうしても default export を使いたくて、Native ESM にも従来の擬似 ESM にも対応したい場合。無理矢理やると、以下のような sindre や ajv でやってるような hack によって、TS 4.4 以前でも両対応っぽくすることはできました。ただしランタイムに対して微妙に副作用はあります。

module.exports = exports = YourDefaultExport;
Object.defineProperty(exports, "__esModule", { value: true });
export default YourDefaultExport;

Native ESM / 擬似 ESM Dual package (module:node12 以降)

module:node12 は package exports フィールド に対応しているため、もう少しクリーンに両対応が可能です。従来から提唱されている ESM / CJS Dual パッケージにうまく型定義を合わせてあげることで、Native ESM (module:node12) からも、従来の擬似 ESM(module:commonjs)からも正しく読み込めるようにできます。

以下に Dual パッケージのサンプルプロジェクトを置きました。

https://github.com/teppeis/ts-esm-cjs-dual

package.json は type:commonjs をベースに conditional exports を使う一般的な Dual パッケージの構成。

{
  "type": "commonjs",
  "main": "./dist/index.js",
  "exports": {
    "import": "./dist/index.mjs",
    "default": "./dist/index.js"
  }
}

dist は index なんちゃらがガチャガチャしますが、ちゃんと動きます。

src
├── index.mts
└── index.ts

dist
├── index.d.mts
├── index.d.ts
├── index.js
└── index.mjs

新しい TypeScript で module:node12 を指定した場合、package exports フィールド に対応しているので、import する場合は dist/index.mjs を参照しようとし、型定義としては dist/index.d.mts を参照します。CJS から require() で読む場合、dist/index.js とその型定義 dist/index.d.ts を参照します。

古い TypeScript や module:node12 等でない場合、package exports フィールド に対応しないので、擬似 ESM や CJS からは普通に maindist/index.js とその型定義 .d.ts を参照して終わりです。

よくできてますね。default export 問題はさておき、型定義の出し分けは覚えておくといつか役立ちそうです。

グローバル変数の型定義が微妙

Node.js の ESM では、CJS で使えていた ___filename などのいくつかのグローバル変数が存在しません。しかし、TypeScript で Native ESM を書く場合、これを型定義にうまく反映する方法が無いようです。

これらのグローバル変数の型は @types/node で定義されたものを使うのが一般的でしたが、ここには Native ESM 用の型定義はありません。

@types や DefinitelyTyped はどうするのか?

現時点では TypeScript の @types やそれを管理している DefinitelyTyped (DT) では、package exports フィールド による dual package 的なものに対応してないようですが、今後どうしていくんでしょうか?今のままだと、各パッケージの型定義がサポートするのは Native ESM か擬似 ESM のどちらか一方のみを選ぶ必要が出てきてしまいます。

この問題について、前述の issue では時間をかければ解決できるのではと言及されてはいます。typesVersions には対応してるので、やればできそうではあります。

周辺ツールの対応状況

数ヶ月前に調べた段階では、ts-node やそれを利用する jest などの周辺ツールの ESM 対応状況は厳しい状況でした。

最近の状況は詳細に追っていませんが、Node.js 側の loader hooks API はまだ experimental で、つい先週リリースされた Node v16.12.0 でも大きく変わったばかりなので、loader 依存の各種ライブラリが安定してくるのはまだ先になりそうです。

また、TypeScript の module:node12 周辺の仕様については、識別子の扱い方が ts-node 等のツールと相性が良くないことが前述の issue で懸念点として挙げられています。この辺りも議論が継続しそうです。

まとめ

ES2015 の策定から 6 年が経過して、ようやく TypeScript で Native ESM を書ける仕様と実装が出てきました。長い間議論しただけあってそれなりに良く考えられている印象で、感慨深いです。

一方で、変更による影響は非常に大きく、周辺エコシステムに関する懸念点やフィードバックも多く集まっているため、次期 TypeScript 4.5 では ESM 対応は stable リリースされず、しばらく nighly リリースのまま継続検討することになりました。まあ妥当な感じがします。そもそもの Node.js の ESM 仕様自体が複雑なので、そのラッパー的な立ち位置である TS の ESM 対応も同程度に複雑になることは仕方のないことだと個人的には思います。むしろ TS 独自の仕様を入れずに Node.js に従う方がシンプルになり、広く簡単に扱う方法はその後に出てくるベストプラクティスや便利ツールに任せるのが良いと思います。

また、従来からたびたび書いている擬似 ESM との default export 互換性問題については、今後も残り、特に型定義を抱え込んだままメンテが止まっているようなパッケージはフォークするしかありません。今後 Pure ESM 化が浸透していく過程で、数年がかりで淘汰されていくのではないかと思います。とはいえ、世の中では Pure ESM やっていきが意外と高まっているので、TS 本体の議論が終われば意外と一気に進むかもしれません。

ということで、Native ESM への道はもう少し続きそうです。なかなか終わらせてくれませんね。

脚注
  1. 実はこの記事をほぼ書き終えた直後に 4.5 で ESM をリリースしない決定が出てきたので、泣きながら記事を修正してます。 ↩︎

  2. import 文の識別子の拡張子に .js を指定すること自体は TS の従来バージョンから可能でした。 ↩︎

  3. https://twitter.com/orta/status/1444958295865937920 ↩︎

  4. 例として sindre 製の HTTP クライアント got は現在 pure ESM 化を v12 系で進めていますが、執筆時点で最新版である v12.0.0-beta-4 は、TypeScript 4.5 で Native ESM から利用しようとすると微妙に型エラーになりました。当初私は TypeScript 側のバグも疑いましたが、調べていくと孫依存の p-cancelable というパッケージの型定義が ESM 対応できてないという原因にたどりつきました(main では修正済)。 ↩︎

Discussion