この記事は TypeScript Advent Calendar 2019 4 日目の記事です
大遅刻してしまってスミマセン・・・
3 日目の記事は @kimromi さんの Flow から TypeScript に段階的に移行する です
5 日目の記事は @hika7719 さんの AWS CDK を使って ECS(Fargate 起動タイプ)を構築する です
TypeScript の tsc コマンドを叩いたときに const x:number = ''; がコンパイルエラーになるまでの道のり
TypeScript がどのようにして型チェックをしているのかとふと疑問に思ったのでコードを読んでみました
実際に tsc コマンドを叩いてから const x:number = '';
という「数字型の変数宣言に文字列の値を代入」するコードがコンパイルエラーとして検出されるまでの流れを一通り読んでみたので、解説してみようと思います
TypeScript のアーキテクチャ概要について
TypeScript のリポジトリの wiki に、TypeScript 本体のアーキテクチャについての概要が記載されています
Architectual Overview のページを見ると、最初に以下の図が掲載されており、ここには TypeScript の機能について、レイヤー毎の役割概観が記述されています
当たり前ですが、型チェックはこのうち Core TypeScript Compiler
という最も低レイヤの位置にありますね
図のすぐ下には Core TypeScript Compiler
の各種機能についての概要があり、これによると Type resolver/Checker
が型チェックを担っていることがわかります
また、 Overview of the compilation process
の項目に型システムについて以下のように記載されており、 TypeChecker
が TypeScript の型システムのコアであることがわかります
From a
Program
instance aTypeChecker
can be created.TypeChecker
is the core of the TypeScript type system. It is the part responsible for figuring out relationships betweenSymbol
s from different files, assigningType
s toSymbol
s, and generating any semanticDiagnostic
s (i.e. errors).
実際にソースコード上では types.ts にて interface が定義されており、 checker.ts にて TypeChecker インスタンスを生成する関数が定義されています
ちなみに自分で TypeChecker を使う場合は wiki のUsing-the-Compiler-APIページに具体例が記載されているのでこちらを覗いてみてください
さて、上記の説明内に出てきた Program
などの用語についてですが Overview のData Structures の項に記載があります
ここには TypeScript が内部的に利用しているデータ型とその役割について説明がされています
この記事でもこれらの用語を利用するので、ざっと眺めておいてもらえるといいかもしれません
*補足
Data Structures には説明がないですが、TypeScript ではエラーメッセージを表現するデータ型として Diagnostic
型があるのでこちらで簡単に説明します
Diagnostic
型は構文エラーや文法エラー、型エラーを始めとしたコンパイルエラー全般だけでなく tsconfig の設定エラーなども含んだ汎用のエラーメッセージを表すデータ型です
TypeChecker 内での型チェック処理においても、型エラーが検出されたらこの Diagnostic インスタンスとしてエラー情報を保持しています
ここまで Overview のページを見てきましたが、実は内部実装について他にも説明しているページがあります
Compiler Internals
情報は少ないですが、Checker という項で TypeChecker について説明がされておりますが、今回の内容とは直接関係ないので割愛します
TypeChecker の設計
*自分がコードを読んで得た情報から考えたことなので、実態にそぐわないかもしれません、あしからずご了承ください
TypeChecker
は Program
を元に生成されます。つまり、型チェックの対象となる TypeScript コード・tsconfig を含めた TypeScript プロジェクト全体に対して一意の型チェック機構が生成されると言えるでしょう
例えば tsconfig の strictNullChecks
といった型チェックに関連する設定が異なれば当然 TypeChecker
の振る舞いも変わるといった具合です
また、 Program
型は型チェック以外にも構文チェックなどの他のチェック処理関数も実装されています
TypeChecker
はこのProgram
が持つチェック処理のうちの一つと捉えられそうです。ちなみに他には構文チェック、コンフィグチェックなどがあります
このへんでそれらのチェック処理が実施されていますね(ここは tsc を実行した際に呼ばれる場所。つまり、tsc 実行時に標準出力に表示されるエラー全般がつくられている)
型チェック処理が Program
の関数の一つとして実装されているということは、各種チェック処理をどういう順番で行うかは呼び出し側に委ねられているといえます。コンパイラ(tsc)と LanguageService という複数の呼び出しパスがありえるためにこのような実装になっているのでしょう
さて、 Program
に対して生成される TypeChecker
ですが、型チェック処理の実行は SourceFile
(≒d.ts,ts ファイル)単位となります
LanguageService
から呼び出されるときは単一ファイルに対して型チェックされるように、といった使い分けのためですね
Program
全体に対して型チェックをしたい場合(tsc の実行時など)は Program
が持つ SourceFile
のインスタンス全てに対してチェック処理を実施、という風に実装されています
もちろんファイル横断のエラーも存在しますが、ファイル単位のエラーとは別の変数で TypeChecker
インスタンス内に保持されます
ここまでは Program
と TypeChecker
の関係、 TypeChecker
がどんな単位で実行されるのかを説明しました
では実際に TypeChecker
はどのようにして型チェックをしているのかを説明していきます
といっても AST Node を上から下まで舐めて、愚直に式や文から型を判定して、最終的に比較すべき型を拾い上げて比較し、型が正しいかどうかを判定しているだけです
TypeScript の型の表現力はやたらめったら豊かなのでチェック関数はめちゃくちゃ複雑ですが・・・
もう少し具体的な話をします
TypeChecker
内には check**
という命名規則で各種型チェック関数が定義されています
この check**
関数は基本的には対象の Node の型などが正しいかどうかを判定し、エラーなら Diagnostic を生成して配列へ格納します
実際にエラーを出力する箇所ではこの配列を参照して全てのエラーメッセージを表示する、といった処理を行うことになりますね
(ちなみにデバッガでこの Diagnostic を持つ配列が参照しにくいため、いつ型エラーが検出されたか?を確認するのは結構骨が折れました・・・)
チェック処理の例として、例えば対象の Node が InferType
( type X = Array<string> extends Array<infer T> ? T : never
の infer T
の部分)だった場合は、 checkInferType
という関数を呼び出して、 infer
キーワードがちゃんと Conditional Types
の文脈で呼ばれているかなどの判定がされることになります
これを非常に多岐にわたる Node の種類に対して個別に実装している、というのが TypeChecker
です。すごい
個人的には今回コードを読んでいて、 isSimpleTypeRelatedTo
という関数で if (s & TypeFlags.Undefined && (!strictNullChecks || t & (TypeFlags.Undefined | TypeFlags.Void))) return true;
という処理がずらずら並んだ泥臭いコードを読めたので満足しています(?)
ちなみに TypeChecker
はパフォーマンスが求められるため、処理の中断のための CancellationToken
だったり、恐らく race condition 対策と思われるコード、遅延評価になるような実装が散りばめられているように見受けられましたが、筆者はそれらは読んでいない(読み解けるかもわからない)です。。無念
tsc 呼び出しから型チェックが実施されるまでの流れ
さてここまで長々と内部実装の概要を説明してきましたが、ようやく実際に tsc コマンド実行からの流れを追っていきます
なお、今回対象となるディレクトリ構成は以下のようなものとします
root
+-- src/
| +-- index.ts
+-- tsconfig.json
const x: number = '';
この root
ディレクトリ配下で npx tsc
コマンドを実行したものとします
サンプルリポジトリ: https://github.com/sisisin-sandbox/dive-checker
tsc のエントリポイントは tsc.ts で、ここから順を追ってコードを読んでいきます
executeCommandLine.ts
に飛びますが、ここは要はコマンドラインインターフェースを定義して引数に応じて Core TypeScript Compiler の呼び出しをしているだけです
今回の場合、引数なしで tsc コマンドを実行しているので executeCommandLineWorker 関数から performCompilation 関数を呼び出してコンパイル処理に入ります
performCompilation
内で emitFilesAndReportErrorsAndGetExitStatus
といういかにも型チェックなどの処理をやっていそうな関数を呼び出しているので中を覗くと emitFilesAndReportErrors 関数の返り値を emitResult と diagnostics という変数に代入しています
emitFilesAndReportErrors の中では Program
インスタンスの持つチェック処理を呼び出していますね。この中の getSemanticDiagnostics
呼び出しが今回探している型チェック処理になります
続けて読み進めます
Program
インスタンスは別途 interface が定義されている関係で定義ジャンプで飛べません。 program.ts
ファイルを直接開いて関数名でファイル内検索して getSemanticDiagnosticsの実装を探します(正攻法は performCompilation
の実装まで戻って const program = createProgram(programOptions);
から辿る、だとは思います)
ヘルパなどが用意されていますが、読み進めていくと実態はgetSemanticDiagnosticsForFileNoCache であることがわかります
この getSemanticDiagnosticsForFileNoCache
内で以下のように Diagnostcs
を作りまくっているところにいますね、 typeChecker.getDiagnostics()
という呼び出しが。TypeChecker
のおでましです
const bindDiagnostics: readonly Diagnostic[] = includeBindAndCheckDiagnostics ? sourceFile.bindDiagnostics : emptyArray;
const checkDiagnostics = includeBindAndCheckDiagnostics ? typeChecker.getDiagnostics(sourceFile, cancellationToken) : emptyArray;
const fileProcessingDiagnosticsInFile = fileProcessingDiagnostics.getDiagnostics(sourceFile.fileName);
const programDiagnosticsInFile = programDiagnostics.getDiagnostics(sourceFile.fileName);
* checker.ts はデカすぎて GitHub で見れないのでここからはリンクは張りません
TypeChecker
も interface が定義されているので、これまた実装を checker.ts
ファイルから直接探します
getDiagnosticsWorker
関数内に checkSourceFile(sourceFile);
という呼び出しがありますが、こいつが SourceFile
に対しての型チェック処理になります
checkSourceFileWorker
が実態ですね。中では色々なチェック関数が呼び出されていますが、 SourceFile
内の各文に対して checkSourceElements
という関数が呼び出されています
// 略
clear(potentialThisCollisions);
clear(potentialNewTargetCollisions);
forEach(node.statements, checkSourceElement);
checkSourceElement(node.endOfFileToken);
checkDeferredNodes(node);
// 略
この checkSourceElement
に飛び、その先の checkSourceElementWorker
に飛ぶと、いました。 Node
の SyntaxKind
に応じてチェック処理を実施している場所です
ちなみにこの checkSourceElementWorker
内の処理中にまた checkSourceElementWorker
を呼び出す再帰構造になっています
// 略
switch (kind) {
case SyntaxKind.TypeParameter:
return checkTypeParameter(<TypeParameterDeclaration>node);
case SyntaxKind.Parameter:
return checkParameter(<ParameterDeclaration>node);
case SyntaxKind.PropertyDeclaration:
case SyntaxKind.PropertySignature:
return checkPropertyDeclaration(<PropertyDeclaration | PropertySignature>node);
// 略
呼び出し元の実装( SourceFile
に紐づく全ての文に対してこの関数を呼び出す)からもわかるように、この checkSourceElementWorker
が型チェックのキモのようです
型チェックの多くはここを起点に行われます
さて、今回目指すのは const x:number = '';
という文です
これは VariableStatement
になるので、その分岐を探っていきますが、コードを追うと型チェック自体は VariableDeclaration
の Node
に対して行われることになるので、 checkVariableDeclaration
を見に行きます
が、ここから先はややこしいところが続くので、チェック処理として呼び出される関数名だけ並べていきます
- checkVariableDeclaration
- checkVariableLikeDeclaration
- checkTypeAssignableToAndOptionallyElaborate
- checkTypeRelatedToAndOptionallyElaborate
- checkTypeRelatedTo
- isRelatedTo
- reportRelationError
- ここで
Diagnostics.Type_0_is_not_assignable_to_type_1
というメッセージが指定される
- ここで
- reportError
- これがゴール。
reportRelationError
で指定されたテンプレートを元に、"" is not assignable to type number
というメッセージが生成される
- これがゴール。
最終的には isRelatedTo
という関数にて x:number
の number
と =''
の''
の 2 つの型が比較され、型が不一致のためにエラーという流れになっています
おわりに
長くなりましたが、 const x:number = '';
がコンパイルエラーになるまでの道のりを追ってみました
今回は非常に簡単な型のチェックだったのでなんとか追い切れましたが、複雑な型になるととても読み切れる気がしないですね。。
ですが、個人的には TypeScript がどうやって型チェックしているかを追う事ができたので満足しています
また何かあったら本体のコードを細かく読んでみたい所存です