🐶

TypeScript 5.0 で追加された verbatimModuleSyntax とは何か?

2023/04/05に公開

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が値として参照されるか、型として参照されるか、参照されないか
  • Foofoo.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等の.ctstype: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というルールがある。

https://typescript-eslint.io/rules/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というルールが追加されていた。

https://typescript-eslint.io/rules/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 も効くし、どの環境でも特にデメリットはない?と思うのでとりあえずこれらを有効化しておくのが良さそう。

参考リンク

Discussion