TypeScriptプロジェクトにおいては、ビルド速度が重要です。TypeScriptのビルド速度はCI/CDの効率に直結します。しかしながら、tscはそこまで速くないことも知られており、tsc以外のツールをTypeScriptのビルドパイプラインに混ぜることもよく行われています。
TypeScript(tsc)のコンパイラオプションの中には、そのようなユースケースをサポートするためのものも存在します。公式ドキュメントではInterop Constraintsとして分類されているもの(の一部)です。この記事ではそれらを紹介します。
isolatedModules
isolatedModulesは古くからあるTypeScriptのコンパイラオプションのひとつで、tsc以外を用いてTypeScriptをトランスパイルするユースケースをサポートしてくれるものです。
TypeScriptの主な仕事はTypeScriptのソースコードに対して型検査を行うことですが、他にトランスパイルという役目も持っています。これは、TypeScriptの構文で書かれたソースコードをJavaScriptに変換し、JavaScript処理系が理解できるようにすることを意味します。(新しいJavaScriptの構文を古いJavaScriptの構文に変換することもトランスパイルに含まれますが、この記事にはあまり関係ないためその側面は取り扱いません。)
型検査はTypeScriptにしかできませんが、トランスパイルはTypeScript以外でも可能であり、現在ではさまざまなツールがTypeScriptのトランスパイルをサポートしています。Babelを始めとして、esbuild、SWCなどが代表的です。
isolatedModulesが解決する問題
トランスパイルを担う多くのツールは、ソースコードをファイル単位で取り扱います。しかしほかのファイルの中身を知らないと、トランスパイル結果が定まらないというケースが存在します。
tscはもともとプロジェクト全体を検査してからトランスパイルするのでこのようなケースも問題なく取り扱えましたが、他のトランスパイラはそれだと困ることになります。そこで、トランスパイラが困るようなケースをエラーにするのがisolatedModulesの役目です。
つまり、isolatedModulesを有効にすると、ちょっとルールが厳しくなります。この追加のルールによって、トランスパイラが困らないことを担保します。
isolatedModulesの動作例
具体的にisolatedModulesの有無で結果が変わるのは、次のような場合です。
export type FooType = string;
export const fooConst = "foo";
import { FooType, fooConst } from "./foo.js";
export { FooType, fooConst };
この例では、isolatedModules無しならば問題なくコンパイルできます。index.tsは次のようにコンパイルされます。
import { fooConst } from "./foo.js";
export { fooConst };
ポイントは、トランスパイル結果からはFooTypeが消えていることです。その理由はお察しの通りです。FooTypeは型でありランタイムには存在しないので、トランスパイル後のコードからは消さなければなりません。
ここで、FooTypeは型だけどfooConstは型ではないので、FooTypeだけを消す必要があります。しかし、このことはfoo.tsの中身を見ないと分かりません。つまり、index.tsのトランスパイル結果が他のファイルに依存していることになります。
このようなファイルは、isolatedModules有効化でコンパイルエラーになります。
src/index.ts:3:10 - error TS1205: Re-exporting a type when 'isolatedModules' is enabled requires using 'export type'.
3 export { FooType, fooConst };
~~~~~~~
エラーの直し方いろいろ
isolatedModulesのエラーを直すためには、このファイルだけを見てトランスパイル結果を確定できるようにする必要があります。
たとえば、上のエラーメッセージで示唆されているように、export type構文を使う方法があります。
import { FooType, fooConst } from "./foo.js";
export type { FooType };
export { fooConst };
この場合、export typeは型のみをエクスポートするために使われる構文なので、トランスパイラは無条件に消去することができます。よって、fooConstのみがエクスポートされFooTypeは全く使われないので、importからも除去されます。
次のような書き方も可能です。
import { FooType, fooConst } from "./foo.js";
export { type FooType, fooConst };
別のやり方として、import側で型であることを明示する方針も可能です。
import type { FooType } from "./foo.js";
import { fooConst } from "./foo.js";
export { FooType, fooConst };
この場合も、FooTypeがimport type構文でインポートされていることにより、FooTypeが型であることを構文的に判断できます1。よって、トランスパイル結果のexportからもFooTypeを消すことができます。
やはり、次のような書き方も可能です。
import { type FooType, fooConst } from "./foo.js";
export { FooType, fooConst };
verbatimModuleSyntax
これは他ツールの利用という文脈とは直接関係がないのですが、上の話に一応関係する話題として紹介します。
verbatimModuleSyntaxが有効の場合、これまで有効だった次のコードはコンパイルエラーとなります。
import type { FooType } from "./foo.js";
import { fooConst } from "./foo.js";
export { FooType, fooConst };
src/index.ts:4:10 - error TS1205: Re-exporting a type when 'verbatimModuleSyntax' is enabled requires using 'export type'.
4 export { FooType, fooConst };
~~~~~~~
つまり、このオプションの有効下では、すでにFooTypeが型だと分かっていても、export側でも明示的にtypeと指示する必要があります。
import type { FooType } from "./foo.js";
import { fooConst } from "./foo.js";
// これならOK
export { type FooType, fooConst };
トランスパイラ的にも、FooTypeの出自を覚える必要がなく、ローカルにトランスパイル結果を決められるという利点があります。しかし、今のところこの利点を生かしたトランスパイラは無さそうです(もしあったとしたら、verbatimModuleSyntaxが有効なコードじゃないとトランスパイルできないという制限が発生することになりますが、そんなトランスパイラは聞いたことがありません)。
同様に、型をimportする場合もtypeと明示する必要があります。
// エラー
import { FooType, fooConst } from "./foo.js";
export { type FooType, fooConst };
// これはOK
import { type FooType, fooConst } from "./foo.js";
export { type FooType, fooConst };
verbatimModuleSyntaxの意義
verbatimModuleSyntaxが真価を発揮するのは、どちらかというとモジュールの副作用周りの話です。というのも、このオプションが有効の場合、import/export宣言のトランスパイルのルールはきわめて単純です。すなわち、「typeと書いてあるものは消す。それ以外はそのまま出力する」というルールになります。
import type { FooType } from "./foo.js";
import { type BarType } from "./bar.js";
import { fooConst } from "./foo.js";
export { fooConst };
export { type FooType };
export type { BarType };
import {} from "./bar.js";
import { fooConst } from "./foo.js";
export { fooConst };
export {};
注目に値するのは、import type { FooType }とimport { type BarType }では挙動が異なっていることです。前者はimport宣言がまるごと消されるのに対して、後者はimport {} が残っています。このように、型だけをインポートする場合でも、typeをどこに書くのかによって意味が変わってくるのです。(verbatimModuleSyntaxが無効の場合、どちらの書き方でも型だけをインポートするimport宣言は消されます)。
一般に、import {} from "./bar.js"は何もインポートしていないからといって消すのは安全ではありません。なぜなら、何もインポートしていなくても、この構文があるとbar.jsが実行されるからです。モジュールに副作用がある場合、インポートするだけで何らかの影響が発生します。
つまり、verbatimModuleSyntaxが無効の場合、TypeScriptが勝手にimport宣言を消してしまい意図した副作用が発生しない問題が発生し得ます。あるいは逆に、TypeScriptがimport宣言を消してくれる仕様に依存していたところ、何かの拍子に消されなくなってしまい問題が起こるということも考えられます。
verbatimModuleSyntaxを有効化しておくことで挙動が明確化され、問題を未然に防げる可能性が上がります。
ちなみに、import自体を消すとインポートされたモジュールが実行されなくなり挙動の変化につながる可能性がありますが、importされている個々の変数を消す(import { fooConst }をimport {}にする)ことはECMAScript仕様上安全に行うことができます。
isolatedDeclarations(仮)
次に紹介するのは、まだTypeScriptに実装されていないアイデア段階のものです。これが将来的に実装されるかどうかは分かりません(2023年12月現在)。
これは次のissueで提案されているもので、複数のパッケージからなるTypeScriptコードベースの型検査を高速化するための野心的なアイデアです。
あまりに野心的なのでこれが本当に実装されるのか、されるならばいつごろ実装されるのかについては不透明です(プロトタイプ的な実装は提出されています)。しかし、TypeScriptチームからもポジティブな反応を得ています。
isolatedDeclarationsのアイデア
isolatedDeclarationsのアイデアは案外簡単で、isolatedModulesの対比として理解することができます。つまり、isolatedModules有効下では.ts → .jsのトランスパイルをtsc以外でもできるようになったように、isolatedDeclarations有効下では.ts → .d.ts、つまり型定義の生成をtsc以外でもできるようになります。
tsc以外でもできるということは、実際の型検査を走らせなくても、構文的な(トランスパイラが行うような)変換によって.tsから.d.tsができるということです。つまり、.tsから.d.tsへの “トランスパイル” を可能にするのがisolatedDeclarationsの目的です。
これが可能になると、複数パッケージにまたがるTypeScriptプロジェクトの型検査を従来より高い並列度で行うことができます。
具体的には、 A ← B ← C という依存があるとすると、従来は型検査を直列に行う必要がありました。
- Aの型検査を行い、Aの
.d.tsが生成される - Bの型検査を行い、Bの
.d.tsが生成される - Cの型検査を行い、Cの
.d.tsが生成される
.tsから.d.tsへの“トランスパイル”が可能になると、次のようになります。
- A, B, Cの
.d.ts生成を並列に行う - A, B, Cの型検査を並列に行う
パッケージ間に依存関係があるにもかかわらず、各パッケージの型検査を並列に行うことができるようになりました。これにより、計算資源があれば所要時間を抑えられるのはもちろん、パッケージ単位のキャッシュなどをより活用できるようになります。
isolatedDeclarationsによる制限
まだ実装されていないのでisolatedDeclarationsの仕様を正確に説明することはできませんが、isolatedModulesの比ではない厳しい制限が課せられるのは間違いないでしょう。
例えば、次のコードはisolatedDeclarations有効下ではエラーとなるでしょう。
export function random() {
return Math.floor(Math.random() * 100);
}
理由を理解するために、TypeScriptが上のコードに対して生成する型定義を見てみましょう。
export declare function random(): number;
ポイントはrandomの返り値です。型定義にはnumberと書かれていますが、これは元のソースコードには書かれていません。つまり、これはTypeScriptが推論したものです。型推論はTypeScriptの専売特許であり、トランスパイラにはできません。よって、これはトランスパイラにはできない処理を要求してしまうので、isolatedDeclarations下では許可されないでしょう。
isolatedDeclarationsが有効でもチェックが通るようにするためには、推論しなくてもいいようにあらかじめ戻り値の型を明記します。
// これならOK
export function random(): number {
return Math.floor(Math.random() * 100);
}
これなら、関数本体部分を除去するだけで型定義が完成します(言い換えれば、ASTの操作だけで.d.tsが生成できます)から、トランスパイルの範疇といえます。
他にもさまざまな制限が課せられます。特に、exportされる変数や関数などについては完璧に型アノテーションが書かれている必要があります。関数の内部実装やexportされない関数などについては、.d.tsに現れないため制限がかかりません。
このように、isolatedDeclarationsの有効下ではTypeScriptの書き味がけっこう変わります。TypeScriptプロジェクトのビルド速度を大きく向上させるにはこれだけの対価が必要ということですね。一応、既存のTypeScript-ESLintルールであるexplicit-module-boundary-typesが似た雰囲気なので、これを利用している方はそこまで違和感なく使えるかもしれません。
まとめ
isolatedModulesが導入された2015年のころから、本家tsc以外のツールでTypeScriptを扱うことは行われていたようです。BabelがTypeScriptをサポートしたのが2018年であり、TypeScriptをサポートするパーサーもその数を増やしています。
このように、TypeScriptの周辺ツールの歴史は長く、今も多様化し続けています。その裏では、TypeScript側の対応も少なからず行われています。この記事では取り扱いませんでしたが、esModuleInterOpなどもその名前の通り、他のツールとの互換性が目的となっている部分もあるものです。
周辺ツールの進化や、それにつられて起こるTypeScript本体の進化を楽しみに見守っていきましょう。
-
実際には、型だけでなく変数も
import typeでインポートすることは可能です。この場合、その変数の“型の側面”のみをインポートして、ランタイムにはインポートしないという意味になります。“型の側面”のみインポートするというのは、おおよそtypeof 変数名の形でのみ利用できるという意味です。 ↩