初めに
この記事はTypeScriptコンパイラ(tsc)の流れ、つまり字句解析から出力までの手順を紹介していく。そのため、"let"宣言を例題に、重要な役割を担う関数を挙げる。
各ステップで多くの関数が次々と呼び出されるので、一つ一つ説明するのは困難である。だからと言って、中間呼び出しを100%無視すると後から処理の手順がわかりにくくなるので、いくつかの中間関数の名前も記録しておく。さらに、90万行に及ぶ tsc 探検に欠かせないデバッガーの使い方も紹介する。
簡単にいうと tsc のコンパイル手順(そしてこの記事の構造)は次に通りになる:
この図の各段階に加え、tsc に間違ったコードを読み込ませることで、エラーの処理も一通り紹介する。
ではまず、デバッガーの使い方から始めよう。
ソースコード分析はdebuggerで行いましょう
参照:TypeScriptレポジトリの"First Steps"
-
ソースをフォークし、パソコンにダウンロード
-
VS Codeでファイルを開く
-
デバッグ用ファイルを準備するため、次のコマンドを端末で実行
cp .vscode/launch.template.json .vscode/launch.jsoncp .vscode/settings.template.json .vscode/settings.json
-
プロジェクトのルーツにテストファイル、例えば
1test.ts、を作成- 名前の最初に'1'をs追加することで
test/cases/compilerという非常に大きいテストフォルダーの真上に残り、後から見つかりやすくなる
- 名前の最初に'1'をs追加することで
-
このままだとテストはデバッガーに認識されないので、
test.tsをtest/cases/compilerフォルダに移動 -
tscソースのどこかにブレーキポイントをつけ、デバッガーを起動
-
ウォッチ欄に次の値を記入:
node.__debugKind node.__debugGetText() source.symbol.declarations[0].__debugKind target.symbol.declarations[0].__debugKindnode.__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(字句解析器)も同時に定義される。つまり、この時点でソースファイルのトークン化はすでに完成している。1777行目のnextToken()で最初のトークンを準備するだけで構文解析を始めることができる。
次に、関数内のparseList(..., parseStatement)で文を解析していく。プログラム全体はあくまで文の並びなので、文を解析することで宣言、式なども同時に解析される。
let宣言の解析
parseStatementは次のよう:
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が呼び出される。ここで結合を行う:
// 未結合ファイルに対して結合を行う
if (!file.locals) {
...
bind(file);
...
}
エラー報告
識別子の結合におけるエラーは主に型確認段階で報告されるので、 binder.ts でのエラー報告は限られている。
よく考えればそれほど珍しくない:識別子の結合を行なっているから、そもそも構文解析段階ですでに確認された「〇〇宣言」ノードしか見て行かない。そこで、唯一発生し得るエラーは、識別子に予約後を使ってしまったことぐらいだ。
例えば、binder.ts のcheckContextualIdentifierで識別子にyieldキーワードを使わなかったか確かめるコードはこちら:
// 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 の約5万行の半分にすら及ばない。TypeScriptの型システムの多様性を考えれば当たり前のことだが、とても記事一本で説明し切れる内容ではない。
したがって、次の例の解説にとどまる:
let hoge: string = 1;
まず、 program.ts のemitWorkerでgetTypeCheckerが呼び出されるところから始まる。型チェッカーを立ち上げるために、initializeTypeCheckerで識別子の結合を終えてから、checkSourceFileWorker (checker.ts)でチェッキングに入る。
forEach(node.statements, checkSourceElement);
ここのnodeはソースファイルノードである、ファイルにおける文をすべて保管しているので、あとはそれらの文をcheckSourceElement(checker.ts)でチェックするだけ。
上記の例に対し、checkSourceElementWorkerで変数宣言文に用いるcheckVariableStatementが呼び出される。そして左辺にstring型アノテーション(注釈)が付いているのに、右辺は'1'だと発見する。
エラー報告
当たり前だが、型確認段階で発生するエラーの多くは識別子と値の型の非互換性が原因になる。
しかし、すでにお気づきだと思いますが、構文解析の説明をしたのにもかかわらず、まだ構文エラーの処理を説明していない。
実は構文・文法は parser.ts の方ではなく、 checker.ts のほうで確かめられる。これはある種の「遅延評価」であり、確かに型をチェックまでは特に文法をチェックする必要はないと言える。
- 文法エラー
変数宣言の型確認を行うcheckVariableStatementの冒頭にcheckGrammarVariableDeclarationList(checker.ts)が呼び出される。
該当する文法は次のとおり:
- 変数宣言リストは空であってはならない
- for..in宣言の左辺は
usingであってはならない - for..in宣言の左辺は
awaitであってはならない
他にcheckGrammarVariableDeclaration(checker.ts)でconst変数がちゃんと初期化されたか確認される。
基本的に、checkGrammar〇〇になっている関数はすべて文法のチェックにあたる。
- 型非互換性エラー
上記の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は文字列ではなかった。
違う例を見てみましょう:
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)に移る。
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 => { };
以上の例でアロー関数の導入以前のES5への変換をデバッグすることができる。
getScriptTransformers(transformer.ts)をみてみると、原版からES5までのバージョンに対応するトランスフォーマーが次々とtransformers配列にpushされる。それはつまり、トランスフォーマーは基本的に一つ前のバージョンにしか該当しない。例えば、ES2017 からES2016 へのトランスフォーマーはあるが、 ES2020 から ES2016 へ変換するには ES2020 → ES2019 → ES2018 → ES2017 → ES2016 と繰り返し変換を行わないといけない。
最後に、getScriptTransformersは該当するトランスフォーマーの配列を返す。
トランスフォーマー適用
トランスフォーマー配列をもらい、emitFiles(emitter.js)に移動する。出力を担当するコードはこちら:
// ソースファイルコンパイルの結果を順番に出力する
forEachEmittedFile(
host,
emitSourceFileOrBundle,
getSourceFilesToEmit(host, targetSourceFile, forceDtsEmit),
forceDtsEmit,
onlyBuildInfo,
!targetSourceFile
);
getSourceFilesToEmitからまだコンパイルされちないファイルをを調べ、emitSourceFileOrBundleで出力を行うわけだ。
やがて、emitJsFileOrBundle(emitter.ts)に辿り着くとトランスフォーマーの出番だ。
transformNodesに移り、次のコードでトランスフォーマーを順番に適用する:
for (const transform of transformersWithContext) {
node = transform(node);
}
return node;
出力
出力のための準備が整った。あとは emitter.ts に戻り、printSourceFileOrBundleを呼び出す。
writeFileから今度 utils.ts に移動し、また program.ts のwriteFileに移転すると… fakesHosts.ts でまた違うwriteFileが呼び出される。だがこれで終わり:
// OSにファイル出力を任せる
this.sys.writeFile(fileName, content);
まとめ
TypeScriptコンパイラほど大規模なコードベースだと、やはり関数Aから関数Bまでの呼び出し連鎖はたいてい長くなる。
このため、この記事でlet文などに直接関わらない中間呼び出しのほとんどを省いた。これでリファレンスとして成り立てればと思います。
さらに、TypeScriptコンパイラの研究にこの記事をご利用いただき、説明に不備を発見されれば、ぜひご指摘ください。
参考資料
Basaratさんの本の最後に「TypeScript Compiler Internals」(TypeScriptコンパイラの内部構造)という章がある。