TypeScript 5.0 で追加された verbatimModuleSyntax とは何か?
TypeScript 5.0 で導入された新しいオプションverbatimModuleSyntax
について少し調べた結果、とりあえず ESLint だけ設定しとこうと思った話。
背景: トランスパイル時の import 文の省略挙動がムズい
TypeScript で以下の import 文を書くと、tsc はどのような JS にトランスパイルするだろうか?
import { Foo } from "./foo";
答えは、状況によって 3 パターンある。
// A: そのまま残る
import { Foo } from "./foo";
// B: 識別子 `Foo` が削除される
import {} from "./foo";
// C: import 文ごと完全に削除される
どれになるかは、以下の状態の組み合わせ依存する(他にも条件あるかも)。
- そのファイルで
Foo
が値として参照されるか、型として参照されるか、参照されないか -
Foo
がfoo.ts
で値として定義されているか、型として定義されているか -
tsconfig
のオプション設定-
importsNotUsedAsValues
: 値として参照されない import 文を残すかどうか -
preserveValueImports
: 参照されない import 識別子が値なら残す -
isolatedModules
: ファイル単位でトランスパイルする
-
上記は出力するモジュール形式が ESM の場合 ("module": "esnext"
等) の例だが、CJS を出力するケースでも同様である。
このような複雑な挙動の問題点は、
- 予期しない副作用や依存ツリーの肥大化
- 複雑な条件を人間が制御し切ることは難しいため、予期せず依存ツリーが大きくなったり、副作用のあるモジュールの読み込み順が変わって挙動が変わってしまったりする。
- tsc 以外のコンパイラで実現困難
- 現代では TypeScript コードのトランスパイルには Babel, esbuild, SWC など tsc 以外のツールを使うことも一般的になってきている。しかし、それらのツールは基本的に型アノテーションを文法的に削っているだけであり、型情報をうまく解釈できない。よって前述したような tsc の挙動を再現することは困難。実際、出力が異なるケースもある。
- これは tsc で
isolatedModules
オプションを有効にした場合も同様
現在、TypeScript ではimport type
構文やインラインの type 修飾子を使うimport {type Foo}
構文が使えるようになり、トランスパイル時に削除すべき import 文を明示的に宣言可能になっている。しかしながら、type
をつけずに従来通りに型を import することもできてしまうため、tsc としては上記の複雑な挙動を落とすことができない状況が続いていた。
そこで新オプションverbatimModuleSyntax
の登場である。
verbatimModuleSyntax
の挙動
verbatimModuleSyntax
オプションを有効にした場合、import 文を逐語的に(verbatim, 一語一語をそのまま)トランスパイルする。import type 文は丸ごと削除され、インライン type 修飾子はその識別子のみ削除される。What you see is what you get! わかりやすい!
// そのまま → import { Foo } from "./foo";
import { Foo } from "./foo";
// 完全に削除される
import type { Foo } from "./foo";
// type Foo が消える → import { Bar } from "./foo";
import { type Foo, Bar } from "./foo";
// type Foo が消える → import {} from "./foo";
import { type Foo } from "./foo";
どうしてそんな単純な仕様にできるのかというと、tsc のコンパイル時の型チェックで、verbatimModuleSyntax
が有効なときは型のみを import する場合に import type 構文またはインライン type 修飾子がついてないとエラーになるため。それによって、import 文の識別子が値なのか?型なのか?など評価する必要なく、type
が付いてたら削除、付いてなかったら残す、というように逐語的なトランスパイルが可能になる。
TypeScript は、トランスパイルにおいて JavaScript のセマンティクスに介入しない、というのを重要なポリシーに掲げているので、本来、このような仕様の方が望ましいと思われる。
じゃあverbatimModuleSyntax
を有効化すればいいんだね、以上、と思ったけど設定によってはそうでもなかった。
verbatimModuleSyntax
と CJS の相性が悪い
verbatimModuleSyntax
を有効化すると、import 文は import 文にしかトランスパイルできない。require()
に変換できないという制約が付く。
つまり、以下のようなケースでは、
-
module:commonjs
で CJS にトランスパイルする -
module:node16
等の.cts
やtype:commonjs
で CJS を書く
モジュールをimport/from
構文で読み込むとエラーになるため、いにしえのimport/require
構文を使う必要がある。厳しい!
// verbatimModuleSyntax が有効な場合、CJS でこれはエラーに!
import { Foo } from "./foo";
// こう書く必要がある
import foo = require("./foo");
const { Foo } = foo;
特に named import との相性が悪く、ちょっと実用する気にはなれない。
TypeScript としてエコシステムに向けてあるべきトランスパイルの仕様を提示したことは意義があるが、ユーザーとしてはオプション有効化にハードルはあり、どうしたもんか。
typescript-eslint で似たようなことを実現する
tsc の細かい挙動は置いといて、とにかく import 周りで曖昧な状況がなくなればよくね?型の import にtype
を強制付与できればそれで良くね?という気持ちになるが、typescript-eslint にそのものズバリな@typescript-eslint/consistent-type-imports
というルールがある。
型の import に対してtype
が付いてないとエラーにできるのはverbatimModuleSyntax
と同様。加えて、こちらは auto-fix に対応してるので、ESLint をエディタに組み込んでいれば人間が気にすることなく自動でtype
が付いたり消えたりしてくれる。もちろん CJS 系でも使える。良い。
もう一つ、import 文が インライン type 修飾子付きの識別子しか持たない場合、verbatimModuleSyntax
が有効かどうかで import 文自体が削除されるかどうかが変わってしまうというのも微妙なので、そういうのを避けたい。
// `verbatimModuleSyntax`有効 → import {} from "./foo";
// `verbatimModuleSyntax`無効 → 文ごと削除
import { type Foo } from "./foo";
これも最近の typescript-eslint のアップデートで@typescript-eslint/no-import-type-side-effects
というルールが追加されていた。
このルールも auto-fix に対応していて、上記のようなインライン type 修飾子は import type 構文に変換されるので、トランスパイル時に必ず文ごと削除され副作用を気にする必要がなくなる。
そもそもインライン type 修飾子っているの?面倒が増えるだけでは?全部 import type 構文で良くね?っていう方面には、eslint-plugin-import のimport/consistent-type-specifier-style
ルールを使うことで、インライン type 修飾子を禁止して、必ず import type 構文を使うように強制ができる。
まとめ
verbatimModuleSyntax
オプションは tsc のトランスパイル時の import の複雑な省略挙動にまつわる問題(意図しない副作用や tsc 以外のコンパイラとの互換性)を解決するが、tsconfig の設定状況によって導入にややハードルがある。
これら問題は、verbatimModuleSyntax
を有効化しなくても、ESLint で@typescript-eslint/consistent-type-imports
と @typescript-eslint/no-import-type-side-effects
を有効化することでほとんど解決できる。auto-fix も効くし、どの環境でも特にデメリットはない?と思うのでとりあえずこれらを有効化しておくのが良さそう。
参考リンク
- Announcing TypeScript 5.0 - TypeScript
- Proposal: deprecate
importsNotUsedAsValues
andpreserveValueImports
in favor of single flag · Issue #51479 · microsoft/TypeScript - Add
verbatimModuleSyntax
, deprecateimportsNotUsedAsValues
andpreserveValueImports
by andrewbranch · Pull Request #52203 · microsoft/TypeScript - Consistent Type Imports and Exports: Why and How | typescript-eslint
Discussion