ソーステキストからASTへ:スキャナー、パーサー、そしてノードシステム
前提知識
- ›第1回:アーキテクチャとコードベースの全体像
- ›字句解析器(lexer/tokenizer)と再帰下降構文解析の基本的な理解
- ›TypeScript構文への習熟(ジェネリクス、JSX、デコレーター、テンプレートリテラル型など)
ソーステキストからASTへ:スキャナー、パーサー、そしてノードシステム
TypeScriptのコンパイルは、ソーステキストの文字列から始まり、フロントエンドの処理として SourceFile ——完全に構築された抽象構文木(AST)——で完結します。この過程を担うのが、テキストをトークンに分割する Scanner と、それらのトークンを木構造に組み上げる Parser という、2つの協調するモジュールです。合わせておよそ15,000行のコードから成るこのペアは、プログラミング言語の世界でも屈指の複雑な文法を扱います。TypeScriptの構文はJavaScriptのスーパーセットであり、その上にジェネリクス、JSX、デコレーター、テンプレートリテラル型、satisfies 式などが積み重なっています。
本記事では、これら2つのモジュールを詳しく解剖し、すべてのノードにラベルを付ける SyntaxKind 分類システムを追い、ASTを整理するNodeの型階層を明らかにします。
SyntaxKind:ノード分類の共通基盤
TypeScriptのAST上のあらゆる要素は、SyntaxKind という単一の const enum によって分類されます。src/compiler/types.ts#L40 で定義されており、triviaとトークンから始まり、キーワードを経て、あらゆる式・文・宣言・JSDocノードまでを網羅しています。
flowchart TD
SK["SyntaxKind (const enum)"]
SK --> Trivia["Trivia (0-8)<br/>Comments, Whitespace, Shebang"]
SK --> Literals["Literals (9-15)<br/>NumericLiteral, StringLiteral, RegExp"]
SK --> Punctuation["Punctuation (16-80)<br/>Braces, Operators, Arrows"]
SK --> Keywords["Keywords (81-165)<br/>if, class, const, type, ..."]
SK --> TypeNodes["Type Nodes<br/>TypeReference, UnionType, ConditionalType"]
SK --> Expressions["Expressions<br/>CallExpression, BinaryExpression, ..."]
SK --> Statements["Statements<br/>IfStatement, ForStatement, ReturnStatement"]
SK --> Declarations["Declarations<br/>ClassDeclaration, FunctionDeclaration, ..."]
SK --> JSDoc["JSDoc Nodes<br/>JSDocComment, JSDocTag, JSDocTypeExpression"]
設計上の重要な規則として、token > SyntaxKind.Identifier であればそのトークンはキーワードを意味します。この単純な比較が、パーサーとスキャナー全体にわたる高速なキーワード検出を支えています。
enumを補完するものとして、関連する種類をまとめたユニオン型エイリアスも用意されています。TypeNodeSyntaxKind は型の位置に現れる構文の種類をすべて収集し、TokenSyntaxKind はスキャナーが生成できるすべてのトークンを対象とします。JsxTokenSyntaxKind と JSDocSyntaxKind は、それぞれの特殊なスキャンモード向けです。これらの型は、スキャナーがコンテキストに応じて返せる値を制約する役割を果たしています。
SyntaxKind と並んで、NodeFlags enumがノードごとのメタデータを保持します。変数が let/const/using のいずれか、ノードが変換中に合成されたか、await や yield のコンテキストで解析されたか、そしてエラーやimportの有無を追うフラグなどが含まれています。
Scanner:ステートフルなトークン化
スキャナーは src/compiler/scanner.ts で定義されています。約4,100行のコードが、単一のクロージャーファクトリーに集約されています。
Scanner インターフェース
公開されている Scanner インターフェース を見ると、その設計思想がよくわかります。これはテキスト上の ステートフルなカーソル です。scan() を呼び出して次のトークンへ進み、getToken()、getTokenStart()、getTokenEnd()、getTokenValue() といったメソッドで現在位置を確認します。トークンの配列は存在せず、パーサーが一度に1つずつトークンを引き出す仕組みです。
インターフェースには reScan* メソッド群も含まれています。reScanGreaterToken()、reScanSlashToken()、reScanTemplateToken()、reScanJsxToken() などです。これらが必要なのは、TypeScriptの文法がコンテキスト依存だからです。> は大なり演算子にも型引数リストの終端にもなり得ますし、/ は除算の演算子にも正規表現の開始にもなり得ます。また } はブロックを閉じる場合もあれば、テンプレートリテラルのスパンを開く場合もあります。
createScanner:クロージャーパターン
flowchart LR
CS["createScanner()"] --> Closure["Closure with var locals"]
Closure --> pos["var pos: number"]
Closure --> endVar["var end: number"]
Closure --> token["var token: SyntaxKind"]
Closure --> tokenValue["var tokenValue: string"]
Closure --> scan["scan() → SyntaxKind"]
Closure --> reScan["reScan*() methods"]
createScanner() は、位置情報・トークン状態・エラーハンドリング用の var ローカル変数をクロージャーで束ねることでスキャナーを生成します。関数の冒頭には、おなじみのTDZ回避コメントが記されています。
// Why var? It avoids TDZ checks in the runtime which can be costly.
// See: https://github.com/microsoft/TypeScript/issues/52924
クロージャー内で let の代わりに var を使うのは、一時的デッドゾーン(TDZ)チェックのランタイムコストを避けるためです。コンパイルのたびに何百万回も呼び出されるホットな関数では、この差が測定可能なパフォーマンス向上につながります。コンパイラー全体でこのパターンが採用されています。
中核となる scan() 関数は、現在の文字コードを対象とした巨大な switch 文です。{、}、(、) のような単純な1文字トークンから、===、>>>= といった複数文字の演算子、エスケープシーケンスを含む文字列リテラル、テンプレートリテラルのスパン、数値リテラル(0x、0o、0b、BigIntの n サフィックスを含む)、そして正規表現まで、あらゆるものを処理します。
スキャンモード
スキャナーは、構文的なコンテキストに応じた複数のモードをサポートしています。
- 通常モード:TypeScript/JavaScriptの標準的なトークン化
- JSXモード:
scanJsxToken()とreScanJsxToken()がJSXのテキストコンテンツと要素の境界を処理する - テンプレートリテラルモード:
reScanTemplateToken()がテンプレートスパンと埋め込み式の切り替えを行う - 正規表現モード:
reScanSlashToken()が/を正規表現の区切り文字として再解釈する。名前付きキャプチャグループやUnicodeプロパティエスケープを含む正規表現構文の完全なバリデーションも行う - JSDocモード:
scanJsDocToken()がJSDocコメントに必要なシンプルなトークン化を処理する
ヒント: パーサーが曖昧なトークンに遭遇した場合、スキャナーをバックトラックさせるのではなく、適切な
reScan*メソッドを呼び出して現在のトークンをコンテキストに合わせて再解釈します。スキャナーのスナップショットを管理するよりもはるかにコストが低い方法です。
Parser:再帰下降によるAST構築
パーサーは src/compiler/parser.ts に実装されており、約10,800行の再帰下降構文解析コードで構成されています。外部から呼び出す主なエントリーポイントは createSourceFile() です。
createSourceFile:エントリーポイント
createSourceFile() はファイル名、ソーステキスト、言語バージョン、オプションのスクリプト種別を受け取り、Parser.parseSourceFile() に処理を委譲します。JSONファイル(ScriptKind.JSON で解析)と通常のTypeScript/JavaScriptファイルの区別を扱い、Node.jsのモジュール解決(ESM vs CJS)のために impliedNodeFormat を設定します。
解析処理はパフォーマンスマーク(beforeParse/afterParse)とオプションのトレースで囲まれており、コンパイラー全体でプロファイリングに使われている計測パターンと同じものです。
再帰下降の構造
パーサーは標準的な再帰下降の方式に従っており、文法の各生成規則がそれぞれ独自の関数を持ちます。parseStatement() は現在のトークンに基づいて parseIfStatement()、parseReturnStatement()、parseVariableStatement() などへとディスパッチします。式の解析は parseBinaryExpressionOrHigher() を通じた優先順位クライミングで処理されます。型の解析は独自の並列階層を持ち、parseType()、parseUnionTypeOrHigher()、parseIntersectionTypeOrHigher() などが連携します。
sequenceDiagram
participant E as External Caller
participant P as Parser
participant S as Scanner
participant F as NodeFactory
E->>P: createSourceFile(fileName, text)
P->>S: createScanner(...)
P->>S: scan() → first token
loop For each statement
P->>S: getToken()
P->>P: parseStatement()
P->>F: factory.createIfStatement(...)
P->>S: scan() → next token
end
P->>F: factory.createSourceFile(statements)
P-->>E: SourceFile
エラー回復と投機的解析
パーサーは堅牢でなければなりません。壊れたコードからも使えるASTを生成する必要があるのは、Language Serviceがエディター機能のためにそれを必要とするからです。これを実現するいくつかの戦略があります。
欠落トークン:期待されるトークンが見つからない場合、パーサーは現在位置にゼロ幅の合成ノードである「missing」ノードを作成し、エラーを報告したうえで解析を続行します。
先読みと投機的解析:<T> が型引数リストなのかJSX要素なのかといった曖昧な構文に対しては、lookAhead() と tryParse() が使われます。lookAhead() は投機的に先読み解析を行い、失敗した場合はスキャナーの状態をロールバックします。tryParse() は同様の動作をしますが、成功した場合はその結果をコミットします。
リスト解析によるエラー回復:文のリスト、引数リスト、パラメーターリストなどに使われるリスト解析関数は、想定されるトークン種別のセットを使って停止や再同期のタイミングを判断し、場違いなトークンをスキップする方法を知っています。
ノード階層とSourceFile
SyntaxKind がすべてのノードを分類し、パーサーがそれらを生成する仕組みを見てきました。次に、型システムがASTノードをどのように整理しているかを見ていきましょう。
横断的関心事のためのサブインターフェース
すべての Node が同じ機能を持つわけではありません。オプションプロパティを持つ単一の大きなインターフェースにするのではなく、TypeScriptのASTはサブインターフェースを活用しています。
classDiagram
class Node {
+kind: SyntaxKind
+flags: NodeFlags
+parent: Node
+pos: number
+end: number
}
class JSDocContainer {
+jsDoc: JSDoc[]
}
class LocalsContainer {
+locals: SymbolTable
+nextContainer: HasLocals
}
class FlowContainer {
+flowNode: FlowNode
}
class Declaration {
+symbol: Symbol
+localSymbol: Symbol
}
Node <|-- JSDocContainer
Node <|-- LocalsContainer
Node <|-- FlowContainer
Node <|-- Declaration
LocalsContainer を実装するノードは locals シンボルテーブルを持ちます。これらは新しいスコープを導入するノード(関数、ブロック、モジュールなど)です。FlowContainer を実装するノードは制御フロー解析に参加します。Declaration を実装するノードには関連する symbol が存在します。
この分離は特定のPRで導入されました。symbol、locals、flowNode を基底の Node インターフェースから取り出し、実際に必要とするサブインターフェースへと移動することで、これらのフィールドを必要としない多くのノード型でのメモリ浪費を削減しています。
SourceFile インターフェース
SourceFile は Declaration と LocalsContainer の両方を継承しており、ASTのルートノードであると同時にファイルレベルのシンボルのコンテナーでもあります。保持する情報は以下の通りです。
statements——トップレベルの文のリストfileNameとpath——ファイルの識別情報text——元のソーステキスト(エラー報告とLanguage Serviceが必要とする)languageVersionとlanguageVariant——解析の挙動に影響するisDeclarationFile——.d.tsファイルは特別な扱いを受けるimpliedNodeFormat——Node.jsのモジュール解決におけるESM vs CJS- モジュール識別子:
externalModuleIndicator、commonJsModuleIndicator
SourceFile はコンパイラー全体を通じた処理の基本単位です。Program は SourceFile オブジェクトの集合を管理し、チェッカーは1度に1つの SourceFile を処理し、エミッターは各 SourceFile を独立して変換・出力します。
NodeFactory:型付きAST構築
パーサーはノード生成に new を使いません。代わりに NodeFactory ——網羅的な型付きファクトリー関数の集合——を使います。factory.createIfStatement(expression, thenStatement, elseStatement) は、正しい kind、適切に型付けされた子ノード、そして事前計算された TransformFlags を持つ IfStatement ノードを生成します。
このファクトリーはパーサーだけでなく、ダウンレベリング時に新しい合成ノードを生成するトランスフォーマーパイプライン(第5回)でも不可欠です。ファクトリーを一元化することで、解析から生まれたノードであれ変換から生まれたノードであれ、ノードの形状とフラグが一貫して保たれます。
ヒント: 変換コードを読む際は
factory.create*とfactory.update*の呼び出しに注目しましょう。updateバリアントは子ノードが実際に変更された場合にのみ新しいノードを生成するため、効率的な構造の共有が可能になります。
次回予告
ソーステキストが完全なAST(SyntaxKind で分類された Node オブジェクトの木)へと変換される仕組みを見てきました。しかし、ASTだけでは名前が何を意味するか、スコープがどのように入れ子になっているかはわかりません。第3回では、BinderがこのASTを走査して Symbol を作成し、シンボルテーブルを構築し、型の絞り込みを可能にする制御フローグラフを構成する過程を追っていきます。