TypeScriptコンパイラの内部："let"宣言のコンパイル手順を追う

## 初めに

この記事はTypeScriptコンパイラ(tsc)の流れ、つまり字句解析から出力までの手順を紹介していく。そのため、"let"宣言を例題に、重要な役割を担う関数を挙げる。

各ステップで多くの関数が次々と呼び出されるので、一つ一つ説明するのは困難である。だからと言って、中間呼び出しを100％無視すると後から処理の手順がわかりにくくなるので、いくつかの中間関数の名前も記録しておく。さらに、90万行に及ぶ tsc 探検に欠かせないデバッガーの使い方も紹介する。

簡単にいうと tsc のコンパイル手順（そしてこの記事の構造）は次に通りになる：

``` mermaid
---
title: TypeScript Compiler Structure
---
flowchart LR
    id1>ソース] --> id2(字句解析)
    id2 --> id3(構文解析)
    id3 --> id4(識別子の結合)
    id4 --> id5(型チェック)
    id5 --> id6(トランスパイル)
    id6 --> id7(出力)
```

この図の各段階に加え、tsc に間違ったコードを読み込ませることで、エラーの処理も一通り紹介する。

ではまず、デバッガーの使い方から始めよう。

## ソースコード分析はdebuggerで行いましょう

参照：[TypeScriptレポジトリの"First Steps"](https://github.com/orta/typescript-notes/blob/master/first_steps.md)

1. ソースをフォークし、パソコンにダウンロード
1. VS Codeでファイルを開く
1. デバッグ用ファイルを準備するため、次のコマンドを端末で実行
    1. `cp .vscode/launch.template.json .vscode/launch.json`
    1. `cp .vscode/settings.template.json .vscode/settings.json`
1. プロジェクトのルーツにテストファイル、例えば`1test.ts`、を作成
    1. 名前の最初に'1'をs追加することで`test/cases/compiler`という非常に大きいテストフォルダーの真上に残り、後から見つかりやすくなる
1. このままだとテストはデバッガーに認識されないので、`test.ts`を`test/cases/compiler`フォルダに移動
1. tscソースのどこかにブレーキポイントをつけ、デバッガーを起動
1. ウォッチ欄に次の値を記入：
   ``` js
   node.__debugKind
   node.__debugGetText()
   source.symbol.declarations[0].__debugKind
   target.symbol.declarations[0].__debugKind
   ```

   `node.__debugKind`と`node.__debugGetText()`はそれぞれノードの種類とソースコードにおける文字列を指している。tscのほとんどの関数はノードを渡されるので、現在のノードを識別するに役立つ
`sourceかtarget.symbol.declarations[0].__debugKind`は型比較のウォッチに用いる。例えば、`T extends string ?`なら：
    - 'T' ＝ source
    - 'string' ＝ target


以上で準備が整った。次はAST（抽象構文木）の生成手順を見ていきましょう。

## AST（抽象構文木）の生成

繰り返しになるが、ここからは tsc ソースコードそのものを解説するために関数から関数へ飛んでいくことになる。だが幸いなことに、間に呼び出される関数のほとんどを省いても特に損はない。呼び出し連鎖に興味のある方はデバッガーを立ち上げ、コールスタックをご確認いただければ十分だ。

### ソースファイルの読み込み

tscのコマンドラインを使用すれば、コンパイル手順はまず`src/compiler/program.ts`から始まる。

`findSouceFileWorker`（program.ts)が指定されたファイル名にあたるファイルを返す。
`createGetSourceFile`（program.ts）でファイルの中身をパーサーに渡す。

以上で、早速`parser.js`に移動する。

### 字句解析と構文解析

まず`parseSourceFileWorker`（parser.ts）が呼び出される。`namespace Parser`に属している関数なので、同じネームスペースの冒頭に`scanner`（字句解析器）も同時に定義される。つまり、この時点でソースファイルのトークン化はすでに完成している。１７７７行目の`nextToken（）`で最初のトークンを準備するだけで構文解析を始めることができる。

次に、関数内の`parseList(..., parseStatement)`で[文](https://www.wikiwand.com/ja/%E6%96%87_(%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0))を解析していく。プログラム全体はあくまで文の並びなので、文を解析することで宣言、[式](https://www.wikiwand.com/ja/%E5%BC%8F_(%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B0))なども同時に解析される。

### `let`宣言の解析

`parseStatement`は次のよう：

``` ts
function parseStatement(): Statement {
    switch (token()) {
        ...
        case SyntaxKind.LetKeyword:
            if (isLetDeclaration()) {
                return parseVariableStatement(getNodePos(), hasPrecedingJSDocComment(), /*modifiers*/ undefined);
            }
            break;
        ...
}
```

要は、`let`宣言なら`parseVariableStatement`（parser.ts）によって解析される。

同時に複数の`let`宣言を行うことができるので（`let x = 1, y = 2, ...`）、`parseVariableStatement`自体では一つ以上の`let`宣言の解析を試みる。もちろん、単一宣言でもいい。

最後に`factoryCreateVariableStatement`(nodeFactory.ts、`createVariableStatement`)で`let`宣言ノードを生成すれば完成。

## 識別子の結合

`emitWorker`(program.ts)の冒頭の`getTypeChecker`で型確認を行う。
この処理は`createTypeChecker`(checker.ts)で行われるが、まず`initializeTypeChecker`経由`bindSourceFile`(binder.ts)で`createBinder`を呼び出す。

そして、多少ややこしいながら、`createBinder`の方で上記と違う`bindSourceFile`が呼び出される。ここで結合を行う：

``` ts
// 未結合ファイルに対して結合を行う
if (!file.locals) {
    ...
    bind(file);
    ...
}
```

### エラー報告

識別子の結合におけるエラーは主に型確認段階で報告されるので、 binder.ts でのエラー報告は限られている。
よく考えればそれほど珍しくない：識別子の結合を行なっているから、そもそも構文解析段階ですでに確認された「〇〇宣言」ノードしか見て行かない。そこで、唯一発生し得るエラーは、識別子に予約後を使ってしまったことぐらいだ。

例えば、binder.ts の`checkContextualIdentifier`で識別子に`yield`キーワードを使わなかったか確かめるコードはこちら：

``` ts
// YieldContextは真ならば、この場所で`await`を使うと非同期処理になるので、
// もちろん識別子として使えない
if (originalKeywordKind === SyntaxKind.YieldKeyword && node.flags & NodeFlags.YieldContext) {
    file.bindDiagnostics.push(createDiagnosticForNode(node,
        // 予約語の誤使用に対する汎用的エラー
        Diagnostics.Identifier_expected_0_is_a_reserved_word_that_cannot_be_used_here,
        declarationNameToString(node)));
}
```

## 型の確認

型の確認は tsc において中軸となる段階だと言える。ソースファイルの長さだけでそれが伝わる： 他の段階をすべて繋ぎ合わせたとしても、checker.ts の約５万行の半分にすら及ばない。TypeScriptの型システムの多様性を考えれば当たり前のことだが、とても記事一本で説明し切れる内容ではない。

したがって、次の例の解説にとどまる：

``` ts
let hoge: string = 1;
```

まず、 program.ts の`emitWorker`で`getTypeChecker`が呼び出されるところから始まる。型チェッカーを立ち上げるために、`initializeTypeChecker`で識別子の結合を終えてから、`checkSourceFileWorker` (checker.ts)でチェッキングに入る。

``` ts
forEach(node.statements, checkSourceElement);
```

ここの`node`はソースファイルノードである、ファイルにおける文をすべて保管しているので、あとはそれらの文を`checkSourceElement`(checker.ts)でチェックするだけ。

上記の例に対し、`checkSourceElementWorker`で変数宣言文に用いる`checkVariableStatement`が呼び出される。そして左辺に`string`型アノテーション（注釈）が付いているのに、右辺は'1'だと発見する。

### エラー報告

当たり前だが、型確認段階で発生するエラーの多くは識別子と値の型の非互換性が原因になる。
しかし、すでにお気づきだと思いますが、構文解析の説明をしたのにもかかわらず、まだ構文エラーの処理を説明していない。
実は構文・文法は parser.ts の方ではなく、 checker.ts のほうで確かめられる。これはある種の「遅延評価」であり、確かに型をチェックまでは特に文法をチェックする必要はないと言える。

1. 文法エラー

変数宣言の型確認を行う`checkVariableStatement`の冒頭に`checkGrammarVariableDeclarationList`(checker.ts)が呼び出される。

該当する文法は次のとおり：

- 変数宣言リストは空であってはならない
- for..in宣言の左辺は`using`であってはならない
- for..in宣言の左辺は`await`であってはならない

他に`checkGrammarVariableDeclaration`(checker.ts)で`const`変数がちゃんと初期化されたか確認される。

基本的に、`checkGrammar〇〇`になっている関数はすべて文法のチェックにあたる。

2. 型非互換性エラー

上記の`let hoge: string = 1;`の例をこのまま用いると、`checkVariableLikeDeclaration`(checker.ts)に辿り着く。ここで、型確認の"匂い"を放つ`checkTypeAssignableTo`を見かける。中を見ると`checkTypeRelatedTo`へのコールになっており、かたチェックは正にここで行われる。

数行下の`isRelatedTo(source, target, ...)`あたりにブレーキポイントを置いてデバッグしてみると、`false`を返す。マウスオーバーすると source は`1`と target は`string`であることがわかる。さらに、`false`を返す直前、`isRelatedTo`は`reportErrorResults`を通じて"Diagnostics.Type_0_is_not_assignable_to_type_1"を報告する。
やはり、`1`は文字列ではなかった。

違う例を見てみましょう：

``` ts
y = 5 // 未定義変数
```

これは、一見識別子の未結合の問題なので型確認と関係ないと思われるかもしれない。しかしよく考えてみれば、識別子がすでに宣言されたかどうか、その識別子の型を確認していない限り、特に必要な情報ではない。

今回は`resolveEntityName`(checker.ts)関数から`getCannotFindNameDiagnosticForName`が呼び出され、案の定"Diagnostics.Cannot_find_name_0"を返す。

## downpile用のトランスフォーマー取得

最後に TypeScript コードを JavaScript コードにトランスパイルしたいところだが、JavaScriptのバージョンによって使えると使えない文法や機能は大幅に変わる。例えば、ES2015(ES6)でアロー関数は初めて導入されたので、ぞれ以前の言語版にアローを書いてみたらエラーになる。したがって、トランスパイルの代わりに"downpile"（以前のバージョンへのトランスパイル）という単語がよく使われる。

downpile を行うために、tscはトランスフォーマーを使う。`src/compiler/transformers`フォルダを調べると、それぞれES版に対するトランスフォーマーの他、分割代入やジェネレーター専用のトランスフォーマーもある。

では、トランスフォーマーはどこで呼び出されるのかというと、型確認の起点である`getTypeChecker`が program.ts の`emitWorker`で呼び出される直後に`getTransformers`(transformer.ts)に移る。

``` ts
export function getTransformers(compilerOptions: CompilerOptions, customTransformers?: CustomTransformers, emitOnly?: boolean | EmitOnly): EmitTransformers {
    return {
        scriptTransformers: getScriptTransformers(compilerOptions, customTransformers, emitOnly),
        // d.tsファイル用のトランスフォーマー
        declarationTransformers: getDeclarationTransformers(customTransformers),
    };
}
```

transformer.tsの働きを試すにはテストファイルの冒頭に`// @target:<版>`を書かないといけない。例えば：

```
// @target:ES5
var arrow1 = a => { };
```

以上の例でアロー関数の導入以前のES５への変換をデバッグすることができる。

`getScriptTransformers`(transformer.ts)をみてみると、原版からES5までのバージョンに対応するトランスフォーマーが次々と`transformers`配列に`push`される。それはつまり、トランスフォーマーは基本的に一つ前のバージョンにしか該当しない。例えば、ES2017 からES2016 へのトランスフォーマーはあるが、 ES2020 から ES2016 へ変換するには ES2020 → ES2019 → ES2018 → ES2017 → ES2016 と繰り返し変換を行わないといけない。

最後に、`getScriptTransformers`は該当するトランスフォーマーの配列を返す。

## トランスフォーマー適用

トランスフォーマー配列をもらい、`emitFiles`(emitter.js)に移動する。出力を担当するコードはこちら：

``` ts
// ソースファイルコンパイルの結果を順番に出力する
forEachEmittedFile(
    host,
    emitSourceFileOrBundle,
    getSourceFilesToEmit(host, targetSourceFile, forceDtsEmit),
    forceDtsEmit,
    onlyBuildInfo,
    !targetSourceFile
);
```

`getSourceFilesToEmit`からまだコンパイルされちないファイルをを調べ、`emitSourceFileOrBundle`で出力を行うわけだ。

やがて、`emitJsFileOrBundle`(emitter.ts)に辿り着くとトランスフォーマーの出番だ。
`transformNodes`に移り、次のコードでトランスフォーマーを順番に適用する：

``` ts
for (const transform of transformersWithContext) {
    node = transform(node);
}
return node;
```

## 出力

出力のための準備が整った。あとは emitter.ts に戻り、`printSourceFileOrBundle`を呼び出す。

`writeFile`から今度 utils.ts に移動し、また program.ts の`writeFile`に移転すると… fakesHosts.ts でまた違う`writeFile`が呼び出される。だがこれで終わり：

``` ts
// OSにファイル出力を任せる
this.sys.writeFile(fileName, content);
```

## まとめ

TypeScriptコンパイラほど大規模なコードベースだと、やはり関数Aから関数Bまでの呼び出し連鎖はたいてい長くなる。
このため、この記事で`let`文などに直接関わらない中間呼び出しのほとんどを省いた。これでリファレンスとして成り立てればと思います。
さらに、TypeScriptコンパイラの研究にこの記事をご利用いただき、説明に不備を発見されれば、ぜひご指摘ください。

## 参考資料

[Basaratさんの本](https://basarat.gitbook.io/typescript/)の最後に「TypeScript Compiler Internals」（TypeScriptコンパイラの内部構造）という章がある。