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 変数名
の形でのみ利用できるという意味です。 ↩