TypeScriptを他のツールで取り扱うためのコンパイラオプションについて

TypeScriptプロジェクトにおいては、ビルド速度が重要です。TypeScriptのビルド速度はCI/CDの効率に直結します。しかしながら、`tsc`はそこまで速くないことも知られており、`tsc`以外のツールをTypeScriptのビルドパイプラインに混ぜることもよく行われています。

TypeScript（`tsc`）のコンパイラオプションの中には、そのようなユースケースをサポートするためのものも存在します。[公式ドキュメント](https://www.typescriptlang.org/tsconfig)では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`の有無で結果が変わるのは、次のような場合です。

```ts:foo.ts
export type FooType = string;
export const fooConst = "foo";
```

```ts:index.ts
import { FooType, fooConst } from "./foo.js";

export { FooType, fooConst };
```

この例では、`isolatedModules`無しならば問題なくコンパイルできます。`index.ts`は次のようにコンパイルされます。

```js:コンパイル後（index.js）
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`構文を使う方法があります。

```ts
import { FooType, fooConst } from "./foo.js";

export type { FooType };
export { fooConst };
```

この場合、`export type`は型のみをエクスポートするために使われる構文なので、トランスパイラは無条件に消去することができます。よって、`fooConst`のみがエクスポートされ`FooType`は全く使われないので、`import`からも除去されます。

次のような書き方も可能です。

```ts
import { FooType, fooConst } from "./foo.js";

export { type FooType, fooConst };
```

別のやり方として、`import`側で型であることを明示する方針も可能です。

```ts
import type { FooType } from "./foo.js";
import { fooConst } from "./foo.js";

export { FooType, fooConst };
```

この場合も、`FooType`が`import type`構文でインポートされていることにより、`FooType`が型であることを構文的に判断できます[^note_export_type_value]。よって、トランスパイル結果の`export`からも`FooType`を消すことができます。

やはり、次のような書き方も可能です。

```ts
import { type FooType, fooConst } from "./foo.js";

export { FooType, fooConst };
```


[^note_export_type_value]: 実際には、型だけでなく変数も`import type`でインポートすることは可能です。この場合、その変数の“型の側面”のみをインポートして、ランタイムにはインポートしないという意味になります。“型の側面”のみインポートするというのは、おおよそ`typeof 変数名`の形でのみ利用できるという意味です。

## verbatimModuleSyntax

これは他ツールの利用という文脈とは直接関係がないのですが、上の話に一応関係する話題として紹介します。

`verbatimModuleSyntax`が有効の場合、これまで有効だった次のコードはコンパイルエラーとなります。

```ts
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`と指示する必要があります。

```ts
import type { FooType } from "./foo.js";
import { fooConst } from "./foo.js";
// これならOK
export { type FooType, fooConst };
```

トランスパイラ的にも、`FooType`の出自を覚える必要がなく、ローカルにトランスパイル結果を決められるという利点があります。しかし、今のところこの利点を生かしたトランスパイラは無さそうです（もしあったとしたら、`verbatimModuleSyntax`が有効なコードじゃないとトランスパイルできないという制限が発生することになりますが、そんなトランスパイラは聞いたことがありません）。

同様に、型を`import`する場合も`type`と明示する必要があります。

```ts
// エラー
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`と書いてあるものは消す。それ以外はそのまま出力する」というルールになります。

```ts:トランスパイル前
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 };
```

```js:トランスパイル後
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コードベースの型検査を高速化するための野心的なアイデアです。

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

あまりに野心的なのでこれが本当に実装されるのか、されるならばいつごろ実装されるのかについては不透明です（[プロトタイプ的な実装は提出されています](https://github.com/microsoft/TypeScript/pull/53463)）。しかし、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 という依存があるとすると、従来は型検査を直列に行う必要がありました。

1. Aの型検査を行い、Aの`.d.ts`が生成される
1. Bの型検査を行い、Bの`.d.ts`が生成される
1. Cの型検査を行い、Cの`.d.ts`が生成される

`.ts`から`.d.ts`への“トランスパイル”が可能になると、次のようになります。

1. A, B, Cの`.d.ts`生成を並列に行う
1. A, B, Cの型検査を並列に行う

パッケージ間に依存関係があるにもかかわらず、各パッケージの型検査を並列に行うことができるようになりました。これにより、計算資源があれば所要時間を抑えられるのはもちろん、パッケージ単位のキャッシュなどをより活用できるようになります。

### isolatedDeclarationsによる制限

まだ実装されていないのでisolatedDeclarationsの仕様を正確に説明することはできませんが、`isolatedModules`の比ではない厳しい制限が課せられるのは間違いないでしょう。

例えば、次のコードは`isolatedDeclarations`有効下ではエラーとなるでしょう。

```ts
export function random() {
  return Math.floor(Math.random() * 100);
}
```

理由を理解するために、TypeScriptが上のコードに対して生成する型定義を見てみましょう。

```ts
export declare function random(): number;
```

ポイントは`random`の返り値です。型定義には`number`と書かれていますが、これは元のソースコードには書かれていません。つまり、これはTypeScriptが**推論**したものです。型推論はTypeScriptの専売特許であり、トランスパイラにはできません。よって、これはトランスパイラにはできない処理を要求してしまうので、`isolatedDeclarations`下では許可されないでしょう。

`isolatedDeclarations`が有効でもチェックが通るようにするためには、推論しなくてもいいようにあらかじめ戻り値の型を明記します。

```ts
// これなら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](https://typescript-eslint.io/rules/explicit-module-boundary-types/)が似た雰囲気なので、これを利用している方はそこまで違和感なく使えるかもしれません。

## まとめ

`isolatedModules`が[導入](https://github.com/microsoft/TypeScript/issues/2499)された2015年のころから、本家`tsc`以外のツールでTypeScriptを扱うことは行われていたようです。[BabelがTypeScriptをサポートした](https://devblogs.microsoft.com/typescript/typescript-and-babel-7/)のが2018年であり、TypeScriptをサポートするパーサーもその数を増やしています。

このように、TypeScriptの周辺ツールの歴史は長く、今も多様化し続けています。その裏では、TypeScript側の対応も少なからず行われています。この記事では取り扱いませんでしたが、esModuleInterOpなどもその名前の通り、他のツールとの互換性が目的となっている部分もあるものです。

周辺ツールの進化や、それにつられて起こるTypeScript本体の進化を楽しみに見守っていきましょう。