Read OSS

3万フィート上空から見るTypeScriptコンパイラ:アーキテクチャとコードベースの全体像

中級

前提知識

  • コンパイラの基本概念(字句解析、構文解析、AST、型システム)に関する基礎知識
  • TypeScript言語の利用者としての実務経験
  • JavaScriptのクロージャとモジュールパターンへの理解

3万フィート上空から見るTypeScriptコンパイラ:アーキテクチャとコードベースの全体像

TypeScript 6.0 は、チームが進めているGoへの書き直しが完了する前の、JavaScript製コンパイラとしての最終リリースです。つまりこのコードベースは、2012年から何百万人もの開発者を支えてきたコンパイラアーキテクチャの、成熟した完成形と言えます。一方で、その規模はおよそ10万行に及ぶ密度の高いクロージャ多用のTypeScriptコードであり、地図なしに踏み込めば途方に暮れてしまいます。本記事はその地図です。

リポジトリの構造を俯瞰し、3つのエントリーポイントをたどり、5フェーズのコンパイルパイプラインを概観します。すべての基盤となる3つのコアデータ構造を紹介し、レイヤードアーキテクチャを理解し、ビルドツールがどのように全体を繋いでいるかを見ていきましょう。

リポジトリのレイアウトとサブプロジェクト構成

TypeScriptのソースコードは src/ 以下に置かれており、TypeScriptのプロジェクト参照を使って複数のサブプロジェクトとして整理されています。src/tsconfig.json のルート設定が13のサブプロジェクトを束ねています。

graph TD
    ROOT["src/tsconfig.json"] --> compiler["src/compiler"]
    ROOT --> services["src/services"]
    ROOT --> server["src/server"]
    ROOT --> tsc["src/tsc"]
    ROOT --> tsserver["src/tsserver"]
    ROOT --> typescript["src/typescript"]
    ROOT --> deprecatedCompat["src/deprecatedCompat"]
    ROOT --> harness["src/harness"]
    ROOT --> jsTyping["src/jsTyping"]
    ROOT --> testRunner["src/testRunner"]
    ROOT --> typingsInstaller["src/typingsInstaller"]
    ROOT --> typingsInstallerCore["src/typingsInstallerCore"]
    ROOT --> watchGuard["src/watchGuard"]
ディレクトリ 役割 おおよその規模
src/compiler コアコンパイラ:scanner、parser、binder、checker、emitter、transformer 約8万行
src/services 言語サービス:補完、診断、リファクタリング、コード修正 約2万5千行
src/server tsserver:プロトコル、セッション管理、プロジェクト管理 約1万5千行
src/tsc tsc コマンドのCLIエントリーポイント 24行
src/tsserver tsserver コマンドのCLIエントリーポイント 57行
src/typescript パブリックAPI エントリーポイント(npmパッケージ) 25行

重要な手がかりは、バレルファイル src/compiler/_namespaces/ts.ts にあります。このファイルは、すべてのコンパイラモジュールを依存関係の順番に従って再エクスポートしています。corePublic.js から始まり、scanner.jsparser.jsbinder.jschecker.js、各 transformer を経て、emitter.js、最後に program.js へと続くこの順序そのものが、コンパイルパイプラインの依存関係グラフになっています。

ヒント: コードベースの中で迷子になったと感じたら、src/compiler/_namespaces/ts.ts に戻りましょう。エクスポートの順番を見れば、どのモジュールが何に依存しているか、そして任意の概念がパイプラインのどこに位置するかが一目でわかります。

エントリーポイント:tsc、tsserver、パブリックAPI

TypeScriptには3つのエントリーポイントがあります。いずれも驚くほど薄い作りになっており、これはコアロジックを再利用しやすくするための意図的な設計です。

flowchart LR
    tsc["src/tsc/tsc.ts<br/>(24 lines)"] --> ecl["executeCommandLine()"]
    tsserver["src/tsserver/server.ts<br/>(57 lines)"] --> session["Server Session"]
    typescript["src/typescript/typescript.ts<br/>(25 lines)"] --> api["Public ts.* API"]
    ecl --> compiler["Compiler Core"]
    session --> ls["Language Service"]
    ls --> compiler
    api --> compiler

tsc CLIsrc/tsc/tsc.ts にわずか24行で定義されています。デバッグログの設定、開発時のソースマップの有効化、標準出力のブロッキングモードへの切り替えを行い、最後に ts.executeCommandLine(ts.sys, ts.noop, ts.sys.args) を呼び出すだけです。このたった1行の呼び出しが src/compiler/executeCommandLine.ts へと処理を委ね、tsc -b によるプロジェクトビルドの performBuild と、通常のコンパイル処理の executeCommandLineWorker に分岐します。

tsserver プロセスsrc/tsserver/server.ts でNode.jsの実行環境を初期化します。ここでは console.log をオーバーライドしてロガー経由にリダイレクトすることで、プラグインが stdio プロトコルを壊さないよう保護したうえで、コマンドライン引数を解析してサーバーセッションを起動します。

npmパッケージのエントリーポイントsrc/typescript/typescript.ts にあります。非推奨警告用のログホストをセットアップしたうえで、ts 名前空間全体を再エクスポートします。import * as ts from "typescript" で取得できるのは、まさにこれです。package.json では mainlib/typescript.js が設定され、bintsctsserver のエントリーが登録されています。

5フェーズのコンパイルパイプライン

TypeScriptの中核は、古典的なマルチパスコンパイラパイプラインです。ソーステキストは5つの異なるフェーズを順に通過し、フェーズを経るごとにより豊かな表現へと変換されていきます。

flowchart LR
    Source["Source Text"] --> Scanner
    Scanner -->|"SyntaxKind tokens"| Parser
    Parser -->|"AST (SourceFile)"| Binder
    Binder -->|"Symbols + FlowNodes"| Checker
    Checker -->|"Types + Diagnostics"| Emitter
    Emitter -->|".js / .d.ts / .map"| Output["Output Files"]
フェーズ ファイル 行数 主なエントリーポイント
Scanner scanner.ts 約4,100 createScanner()
Parser parser.ts 約10,800 createSourceFile()
Binder binder.ts 約3,900 bindSourceFile()
Checker checker.ts 約54,400 createTypeChecker()
Emitter emitter.ts 約6,400 emitFiles()

Scanner はソーステキストをトークナイズして SyntaxKind トークンのストリームに変換します。Parser はそのトークンを再帰下降法で消費し、SourceFile の AST を構築します。Binder は AST を走査して Symbol オブジェクトを生成し、制御フローグラフを構築します。Checker は54,000行超という巨大なモジュールで、型チェック、型推論、割り当て可能性の分析を担います。Emitter は AST を一連の構文変換パスに通したうえで、JavaScript、宣言ファイル、ソースマップへとシリアライズします。

各フェーズはクロージャベースのモジュールとして実装されています。checker だけでも、createTypeChecker() の内部に数百もの var ローカル変数が宣言されています。これは偶然ではなく、コードベース全体を通じて明示的なパフォーマンス上の理由が示されています。

// Why var? It avoids TDZ checks in the runtime which can be costly.
// See: https://github.com/microsoft/TypeScript/issues/52924

このパターンについては、後続の記事で詳しく掘り下げます。

3つのコアデータ構造:Node、Symbol、Type

3つのインターフェースが、他のすべての土台を形成しています。それぞれがいつ、どこで生成されるかを理解することが、コードベースを読み解く鍵です。

classDiagram
    class Node {
        +kind: SyntaxKind
        +flags: NodeFlags
        +parent: Node
        +pos: number
        +end: number
    }
    class Symbol {
        +flags: SymbolFlags
        +escapedName: __String
        +declarations: Declaration[]
        +members: SymbolTable
        +exports: SymbolTable
    }
    class Type {
        +flags: TypeFlags
        +symbol: Symbol
        +aliasSymbol: Symbol
        +aliasTypeArguments: Type[]
    }
    Node --> Symbol : "declaration.symbol"
    Symbol --> Type : "via SymbolLinks.type"
    Type --> Symbol : "type.symbol"

Nodesrc/compiler/types.ts#L942-L955)は AST ノードです。すべてのノードは kind(巨大な SyntaxKind 列挙型の値)、flagslet/const や export 状態、パーサーのコンテキストといったメタデータ)、バインディング時に設定される parent ポインタ、そしてテキスト範囲(pos/end)を持ちます。ノードは parser によって生成され、生成後は変更されません。

Symbolsrc/compiler/types.ts#L6037-L6054)は宣言の意味的な識別子です。Symbol は binder によって生成されます。flags によって Variable、Function、Class、Interface などに分類され、escapedNamedeclarationsmembersexports の配列を持ちます。同一名の宣言が複数ある場合(たとえばインターフェースが2回宣言されているケース)、それらは1つの Symbol にマージされます。

Typesrc/compiler/types.ts#L6439-L6455)は型システム内部の表現です。Type は checker によって遅延生成されます。flags によって String・Number・Object・Union・Intersection・Conditional などに分類されます。対応する symbol への参照と、ジェネリック型の解決に使われる permissiveInstantiationrestrictiveInstantiation などのキャッシュを保持します。

これら3つの構造体の間でデータが受け渡されるタイミングは明確です。parser が Node を生成し、binder が宣言 Node に Symbol を紐付け、checker が SymbolLinks というサイドチャネルを通じて Symbol から Type を遅延計算します。

レイヤードアーキテクチャ:Compiler → Services → Server

コードベースは3つのアーキテクチャレイヤーで構成されており、各レイヤーはその下のレイヤーを基盤としています。

flowchart TB
    subgraph "Layer 3: Server"
        tsserver["tsserver entry"]
        session["Session (protocol dispatch)"]
        projService["ProjectService (multi-project mgmt)"]
    end
    subgraph "Layer 2: Language Service"
        ls["LanguageService API"]
        completions["Completions"]
        diagnostics["Diagnostics"]
        refactors["Refactors / Codefixes"]
    end
    subgraph "Layer 1: Compiler Core"
        program["Program"]
        checker["Type Checker"]
        emitter["Emitter"]
        parser["Parser"]
        scanner["Scanner"]
        binder["Binder"]
    end
    tsserver --> session
    session --> projService
    projService --> ls
    ls --> program
    program --> checker
    program --> emitter
    program --> parser
    parser --> scanner
    program --> binder
    completions --> checker
    diagnostics --> checker
    refactors --> checker

Layer 1(Compiler Core)src/compiler/ — 純粋なコンパイルパイプラインを提供します。Program オーケストレーターがファイルの解決、checker のライフサイクル管理、emit の調整を担います。このレイヤーはエディタやサーバーについて一切知りません。

Layer 2(Language Service)src/services/Program をエディタ向けのAPIでラップします。src/services/services.tscreateLanguageService() が補完・診断・定義ジャンプ・参照検索といったメソッドを提供します。宣言マージにより node.getSourceFile()node.getChildren() といった便利メソッドもASTノードに追加されています。

Layer 3(Server)src/server/ — 言語サービスをJSONワイヤプロトコル越しに公開します。src/server/session.tsSession クラスが、補完・診断・リネームといった受信リクエストを適切な LanguageService メソッドにディスパッチします。src/server/editorServices.tsProjectService は、ワークスペース内の複数プロジェクトを管理します。具体的には、設定済みプロジェクト(tsconfig.json ベース)、推論プロジェクト(個別ファイル向け)、外部プロジェクト(ビルドツール連携)の3種類を扱います。

ビルドシステムと診断メッセージの生成

TypeScriptはタスクランナーとして hereby、バンドラーとして esbuild を使用しています。ビルドタスクは Herebyfile.mjs に定義されています。

flowchart LR
    diagnosticMessages["diagnosticMessages.json"] -->|"generate-diagnostics"| generated["diagnosticInformationMap.generated.ts"]
    generated --> compile["build-src (tsc --build)"]
    compile -->|"emitDeclarationOnly"| dts[".d.ts files"]
    dts --> bundle["esbuild bundler"]
    bundle --> lib["lib/typescript.js<br/>lib/tsc.js<br/>lib/tsserver.js"]

ビルドには巧みな2段階設計が採用されています。src/tsconfig-base.json では emitDeclarationOnly: true が設定されており、TypeScript は宣言ファイルと宣言マップのみを生成します。実際の JavaScript バンドルは esbuild が担当します。これにより、tsc による完全な型チェックを維持しつつ、JavaScript の出力は esbuild の高速処理で行うという最良のバランスが実現されています。

Herebyfile.mjs の esbuild 設定では、CJS形式で es2020 + node14.17 をターゲットとし、すべてをシングルファイルにバンドルします。typescript.js のバンドルには巧妙なラッパーが適用されています。モジュール全体を var ts = {}; ((module) => { ... })({ get exports() { return ts; } }) で包むことで、CJSの利用者と Monaco の ESM バンドリングの双方に対応しています。

診断メッセージの真実の源は src/compiler/diagnosticMessages.json です。各メッセージには人間が読めるキー、カテゴリ(Error、Warning、Message、Suggestion)、および一意の数値コードが定義されています。コード生成ステップがこのJSONを diagnosticInformationMap.generated.ts に変換することで、Diagnostics.Unterminated_string_literal のようなカテゴリとコードを含む型付き定数がコンパイラから使えるようになります。

ヒント: TS1002 のような TypeScript エラーコードを見かけたら、diagnosticMessages.json"code": 1002 を検索してみましょう。対象のメッセージテンプレートが見つかり、そこから生成された定数を grep することで、checker や parser のどこでそのエラーが報告されているかを特定できます。

次のステップ

この地図を手に入れたことで、いよいよ詳細を掘り下げる準備が整いました。Part 2 では、コンパイラのフロントエンド — Scanner と Parser — に焦点を当て、生のソーステキストがどのようにして構造化された AST へと変換されるかを解き明かします。あらゆるノードを分類する SyntaxKind 列挙型を追い、クロージャベースの scanner を解剖し、再帰下降 parser がトークンから SourceFile を組み立てる過程を見ていきましょう。