⚙️

TypeScript Compiler が型チェックをするまでを追う

に公開

最近 TypeScript Compiler の中身とたわむれる機会がありました。[1]これを機にもっと詳細な仕組みを見てみました。

ざっくりまとめ

  • tsconfig を読みこんでエントリーポイントのファイルを決定する
  • エントリーポイントのファイルを Parse する。その後 import などの依存を辿って再帰的に Parse しつつ、lib.*.d.ts@types/* も含めて Program を構築する。
  • AST を走査して宣言から Symbol を生成したり、Symbol Table を構築する。
  • 必要になったら AST から Symbol を取り出して Type を作る。それらを元に型チェックを行う。
  • (おまけ) 構造上 Rust で port するのはむずい

TypeScript Compilerの大まかな処理フロー

TypeScript Compiler の処理フローは大きく以下の 5つの工程に分けられます。

  1. Tokenize
    ソースコードをトークン列に分解する。

  2. Parse
    トークン列を AST に変換する。

  3. Bind
    AST から Symbol, Symbol Table を生成し AST と結びつける。

  4. Type Inference & Type check
    AST と Symbol から型推論をしたりその情報を元に型チェックを行う。

  5. Transform/Emit
    TypeScript ソースコードを JavaScript もしくは型定義ファイル(.d.ts)に変換する。

このレイヤでの解説については How the TypeScript Compiler Compilesを参考にしてください。

詳細な内部構造

では実際に Compiler 内で上記の処理はどのように行われていくのかを実装を詳細に見ていきます。TypeScript のリポジトリには現在は削除されていますがコンパイラのアーキテクチャを説明した図が公開されていました。

TypeScript の Architectural Overview
Architectural Overview

https://github.com/microsoft/TypeScript/wiki/Architectural-Overview/1afea54fbb7a4af15d613708ac0d1951f73aca14

この図によると、checker.ts、parser.ts、scanner.ts などの主要コンポーネントは Core TypeScript Compiler に分類されています。この基盤の上に tscLanguage service があります。tsc はバッチコンパイル用の standalone プログラムであり、Language service はエディタのような対話型アプリケーション向けです。さらに tsserver は Language service のラッパーとして JSON プロトコルによる対話を可能にします。

本記事では説明を簡略化するために tsc によるコンパイルを前提に Core TypeScript Compiler の流れをみていきます。ただし Transform/Emit フェーズについては触れません。

1. 初期設定とファイル解決

最初に示した処理順ではまずソースコードを tokenize しますが、いきなりそこから始まるわけではありません。実際のプロジェクトで単一の.tsファイルのみで完結することは稀であり、通常外部の npm package への依存や他ファイルへの依存があるでしょう。

まず tsc はコマンドライン引数を処理した上で tsconfig.json の内容を確認します。このステップは地味ですが非常に重要です。なぜなら、tsconfig.json がコンパイル対象となるファイルのエントリーポイントを決定するからです。最終的に型チェック、コンパイルを行う際の対象範囲がこの時点で決まります。

具体的にはincludeexcludefilesなどの設定を見てエントリーファイルを決定します。getConfigFileSpecsという関数では tsconfig から得た path を正規化します。
https://github.com/microsoft/TypeScript/blob/c63de15a992d37f0d6cec03ac7631872838602cb/src/compiler/commandLineParser.ts#L3075-L3154

そしてgetFileNamesFromConfigSpecsでそれをもとにファイルシステムを探索しながらフィルタリングします。これを経て解析すべきファイルのエントリーポイントが決まり後続の処理へ続きます。

https://github.com/microsoft/TypeScript/blob/c63de15a992d37f0d6cec03ac7631872838602cb/src/compiler/commandLineParser.ts#L3894-L3981

他にも TypeScript の型チェックの挙動を制御する Compiler Options もこの時点で設定されます。

2. Program の作成

tsconfig 解析後、解析すべきファイルが決定されると、次にProgramオブジェクトを作成します。これも最初に示した処理順には登場しない単語です。Programはコメントによると次のようなものを指しています。

https://github.com/microsoft/TypeScript/blob/c63de15a992d37f0d6cec03ac7631872838602cb/src/compiler/program.ts#L1489-L1499

A Program is an immutable collection of 'SourceFile's and a 'CompilerOptions' that represent a compilation unit.

すなわち、TypeScript の Compilation Unit を表現するオブジェクトで、主に以下の要素から構成されます。

  • Host
    コンパイラが動作する環境(Node.js 環境やブラウザ環境など)とのインターフェースを提供する抽象レイヤー。ファイルの読み書きやモジュール解決などを担当する。

  • Compiler Options
    コンパイル時の設定オプション。

  • SourceFile
    各ソースファイルに対応する AST のルートノード。1ファイルにつき1つの SourceFile オブジェクトが生成される。ここにソースコードの AST が格納される。

このProgramを構築する関数が createProgram です。この関数内でエントリーポイントとなるファイル群に対して host.getSourceFile を呼び出しその中で Tokenize と Parsing が実行されます。

各ファイルは Scanner がトークンを逐次生成し、それを Parser が消費しながら SourceFile をルートとした AST として構築します。

Parsing によって AST へと変換した後再度走査し他ファイルへの依存を収集します。主な依存関係としては、以下のものがあります。

  • ES Module Syntax(import 宣言やexport ... fromなど)
  • CommonJS Module Syntax(require 呼び出しなど、JavaScript ファイルの場合)
  • Dynamic Import(import("...")
  • Triple-slash Reference Directives
    • /// <reference path="..." />
    • /// <reference types="..." />
    • /// <reference lib="..." />

これらの依存関係が検出されると、Host のモジュール解決機能を用いて依存先のファイルパスが特定され、解決されたファイルに対しても同様の処理が再帰的に行われます。このプロセスは新規に追加すべきファイルがなくなるまで繰り返されます。

ただし Program の入力は import で見つかったファイルだけではありません。createProgram は tsconfig の types を参照して @types/* の宣言ファイルを自動で追加します。types を省略した場合は typeRoots(デフォは node_modules/@types)が参照されます。

さらに lib に基づいて lib.dom.d.ts のような標準ライブラリ宣言も追加します。これらはコード側で明示的に import されないのでグローバル宣言を提供するため、型チェックの前提として Program に含める必要があります。

最終的に、型チェックに必要として Program に取り込まれたファイルが AST (SourceFile)として Program オブジェクトに保持されます。

3. Binding

Parsing を経て各ソースコードが再帰的に処理され AST になりました。しかしこれだけでは TypeScript の型チェックには不十分です。

Parsing の工程で見ているのはあくまでソースコードが文法に沿っているかどうかのみなので、例えば次のコードはこの時点で弾かれます。

let : number = 10;
   ^^^

上記のコードは以下の文法に沿っていないため構文エラーになります。

SimpleVariableDeclaration:
    BindingIdentifier TypeAnnotationopt Initializeropt

一方で次のコードは一瞬でエラーだとわかりますが、構文エラーではありません。なぜなら文法には沿っているため構文としては正しいからです。

let x: number = "hello";

構文エラーではない代わりに次のようなエラーが出ます。

let x: number = "hello";
    ^
Type 'string' is not assignable to type 'number'.

あまりにも単純な例ではあるものの、TypeScript のメリットの1つは「構文上は正しいが、意味的には誤っているコードを検出する能力」です。ではこれをプログラムで適切に検出するにはどうすれば良いでしょうか。ここで登場するのが Binding と言うフェーズです。大きく分けて次のようなことを行います。

  1. Symbol の作成
    変数、関数、Class などの Declaration を Symbol として表現する。

  2. Symbol Table の構築、Merge
    Identifier から Symbol へのマッピングを作成する。

  3. 制御フローグラフの構築(本記事では省略)
    if 文などの制御構造を解析する。

Symbolの作成

この Binding はbindSourceFileという関数が起点になっています。
Binder と呼ばれるプログラムが AST を Visit し、特定の AST Node に到達すると Binder が Symbol を作成します。

Symbol とは TypeScript のセマンティクスの根幹をなすものです。ES6のSymbolとは異なるものであることに注意してください。 Symbol は独自定義された次のようなオブジェクトです。

https://github.com/microsoft/TypeScript/blob/0fb5e3a8cff7f18a30cdcee2ead37c727674ccc5/src/compiler/types.ts#L6024-L6041

Symbol が TypeScript のセマンティクスを構成する重要な要素です。例えばflagsにはその Node がどのような文脈で宣言されているのかといった情報をビットフラグで持っています。他にも Symbol 同士の関係や overload のための Declaration された地点の配列などの情報があります。

たとえば次のような単純な ts コードを考えてみましょう。

const foo: number = 1;
// https://ts-ast-viewer.com/#code/MYewdgzgLgBAZiEAuGYCuBbARgUwE4wC8MAjANxA
{
  "kind": "SourceFile",
  "pos": 0,
  "end": 22,
  "children": [
    {
      "kind": "FirstStatement",
      "pos": 0,
      "end": 22,
      "children": [
        {
          "kind": "VariableDeclarationList",
          "pos": 0,
          "end": 21,
          "children": [
            {
              "kind": "VariableDeclaration",
              "pos": 5,
              "end": 21,
              "children": [
                {
                  "kind": "Identifier",
                  "pos": 5,
                  "end": 9
                },
                {
                  "kind": "NumberKeyword",
                  "pos": 10,
                  "end": 17
                },
                {
                  "kind": "FirstLiteralToken",
                  "pos": 19,
                  "end": 21
                }
              ]
            }
          ]
        }
      ]
    },
    {
      "kind": "EndOfFileToken",
      "pos": 22,
      "end": 22
    }
  ]
}

Binder がこの AST を処理する際、VariableDeclaration ノード(foo の宣言部分)に到達すると、以下の処理を行います。

  1. 新しい Symbol の生成
    foo という名前の新しい Symbol オブジェクトが生成される。この Symbol には、foo がブロックスコープ変数であることを示すSymbolFlags.BlockScopedVariableなどの情報が含まれる。

  2. AST ノードへの Symbol の紐付け
    生成された Symbol への参照が、VariableDeclaration Node の symbol プロパティに直接設定される。これにより、ASTノードから対応するSymbolへ直接アクセスできるようになる。

  3. Symbol への AST Node の紐付け
    逆に、Symbol オブジェクトも、自身がどの AST node から宣言されたかの情報を持つようになる。通常、Symbol オブジェクトは declarations というプロパティを持ち、これはそのシンボルに対応する宣言ノードの配列である。

Symbol table

Binder は Symbol を作成するのと同時に、Symbol Table も構築していきます。Symbol Table の構造は非常に単純で単なる名前から Symbol へのマップです。

https://github.com/microsoft/TypeScript/blob/c63de15a992d37f0d6cec03ac7631872838602cb/src/compiler/types.ts#L6213

Symbol は主要なプロパティとしてmembers, exports, globalExportsという Symbol Table を持っており、宣言の種類によってどこに構築するか決まります。またFunctionDeclarationSourceFileといったコンテナ系の特別な AST Node にはlocalsという Symbol Table が存在しています。
変数名が示す通りそれぞれの用途はそれぞれ異なっています。

プロパティ 用途
locals ローカルスコープの変数、関数。Symbol ではなく Node に存在
exports モジュールのexport
members ClassやInterfaceのメンバ
globalExports UMD などがグローバル名前空間に公開するシンボル

この Symbol Table により Type Check の際に Symbol のスコープ内で Identifier がどの宣言を参照しているかを解決できるようになります。

例としてグローバルにfooという宣言と変数barという宣言があるとします。また関数bazの中でも同名のfooという変数宣言があるとします。この時それぞれのconsole.log()に与えているfooはどこに結びついていると判断すれば良いでしょうか。

const foo = 1;
const bar = 2;

function baz() {
  const foo = 2;
  console.log(foo); // <-- どっちのfoo?
  console.log(bar);
}
console.log(foo); // <-- どっちのfoo?

このコードのグローバルスコープの SymbolTable(locals)においてそれぞれの名前に対応している Symbol は次のようになります。

- "foo" -> Symbol { declarations: [const foo = 1], ... }
- "bar" -> Symbol { declarations: [const bar = 2], ... }
- "baz" -> Symbol { declarations: [function baz], ... }

関数bazにおける SymbolTable(locals)から見ると次のようになります。

"foo" -> Symbol { declarations: [const foo = 2], ... }

Binder によって構築された locals SymbolTable をもとに、型チェック時に Checker は識別子を解決します。console.log(foo)fooを解決する際、Checker は直上の関数bazの locals SymbolTable を参照します。その結果、const foo = 2の方を参照しているとわかります。

barbazの locals SymbolTable には存在しません。そのためさらに 1 つ外側のコンテナへ探索範囲を広げます。結果const bar = 2を指しています。

一方でグローバルの方のconsole.logはグローバルスコープの locals で解決すると、const foo = 1の方を指していると判断できます。

このような名前解決ができることで同一ファイル宣言内での Interface, Namespace の宣言マージや重複検知も行うことができます。

4. Type Checking

Binding が完了すると、型推論および型チェックの準備が整います。Binding が終わった時点でProgramに含まれている AST に対して次のことが完了しています。

  • 各宣言に対する Symbol の構築
  • AST -> Symbol の参照
  • Symbol -> AST への参照
  • 親 Symbol <-> 子 Symbol
  • SymbolTable の構築と同一ファイル内での宣言マージと重複検知
  • 制御フローグラフの構築(省略)

AST から Symbol を引くことができるようになり、flag や SymbolTable が参照できるようになりました。また、親/子へのアクセスもすでにできるため同様に親/子の Symbol, Symbol Table にもアクセスできるようになりました。
ざっくり図で表すと次のような形になっています。

TypeScript CompilerのAST・Symbol・SymbolTableの関係図

ここからいよいよ型チェックを行います。例として次の単純な.tsコードの型チェックを行いましょう。

// foo.ts
export const add = (a: number, b: number) => {
  return a + b;
};

// bar.ts
import { add } from "./foo";
add(1, "2"); // Type Error!

Type Checker は checker.ts に実装されています。このファイルは最も巨大で、5 万行を超えるコードから構成されています。
Type Checker の主な責務は以下の通りです。

  1. 型の解決
    Symbol から実際の Type オブジェクトを計算する
  2. 型推論
    型注釈がない場合に文脈から型を推論する
  3. 型の互換性チェック
    代入や関数呼び出しにおける型の互換性を検証する
  4. エラー報告
    型エラーを検出し診断メッセージを Emit する

重要な点として、Binder は Symbol を作成するが型情報は持ちません。 Symbol はあくまで宣言を表現するものです。その宣言が「どのような型を持つか」は Type Checker が必要になったタイミングになって初めて計算します。tsserverはこの仕組みをわかりやすく活用しており、対象ファイルを起点に型チェックを実行します。

別の応用で言うとtypescript-eslintもこの仕組みを活用しています。typescript-eslintは一部のルールは TypeScript Compiler の型情報に基づいて行われます。すべてのルールやコードに対して必要とは限らないということです。
Binding まで済ませておけば AST から Symbol の逆引きと型情報の導出が可能ですから、特定のルールで型情報が必要な Node に当たった時のみ型情報を取り出してそれを活用できます。

Type Check

型チェックは createTypeChecker 関数で作成される TypeChecker オブジェクトを通じて行われます。

この時点で TypeChecker は初期化処理として initializeTypeChecker を実行します。SourceFilebindSourceFile した後、lib.*.d.ts@types/*.d.ts が提供するグローバル宣言を globals に集約します。内部的には mergeSymbolTable でマージします。これにより、コード側で明示的に import していないグローバル API でも名前解決できるようになります。

その後、各SourceFileに対して checkSourceFileWorker が呼び出され、AST を走査しながら型チェックが実行されます。

まず foo.ts の add 関数がどのように型チェックされるか見ていきましょう。

export const add = (a: number, b: number) => {
  return a + b;
};

Binding 完了時点で add には以下のような Symbol が作成されています。

Symbol {
  escapedName: "add",
  flags: BlockScopedVariable | ExportValue,
  valueDeclaration: VariableDeclaration,
  declarations: [VariableDeclaration]
}

この Symbol には型情報がまだ含まれていません。型情報は getTypeOfSymbol 関数を通じて作られます。

add は変数宣言なので、さらに getTypeForVariableLikeDeclaration へと処理が続きます。ここで重要な変数に型注釈があるかどうかという分岐があります。

// わかりやすい部分のみ抜粋
function getTypeForVariableLikeDeclaration(declaration): Type | undefined {
  // 型注釈があればそれを使用
  const declaredType = tryGetTypeFromEffectiveTypeNode(declaration);
  if (declaredType) {
    // If the catch clause is explicitly annotated with any or unknown, accept it, otherwise error.
    return declaredType;
  }

  // 型注釈がなければ初期化式から推論
  if (hasOnlyExpressionInitializer(declaration) && !!declaration.initializer) {
    const type = checkDeclarationInitializer(declaration, checkMode);
    return type;
  }
}

add には型注釈がないため、初期化式であるアロー関数から型を推論します。

シグネチャの構築

関数の型を表現するために、Checker は Signature オブジェクトを構築します。checkFunctionExpressionOrObjectLiteralMethod がこれを担当します。パラメータ a と b にはそれぞれ: number という型注釈があります。これらの型は getTypeOfSymbol を通じて numberType として解決されます。

返り値型の推論

add 関数には返り値の型注釈がありません。この場合、Checker は関数本体から返り値型を推論します。getReturnTypeFromBody が ReturnStatement を収集して型を解析します。

a + b という二項演算の型チェックでは、両オペランドが number であることから、結果も number と推論されます。number + number -> number
最終的に add の型は以下のように計算されます。

Type {
  callSignatures: [
    Signature {
      parameters: [a: number, b: number],
      returnType: number,
      minArgumentCount: 2
    }
  ]
}

bar.ts の型チェック

次に bar.ts を見ていきましょう。

import { add } from "./foo";
add(1, "2"); // Type Error!

ここで重要なのは、bar.tsaddfoo.tsの add がどのように結びつくかです。
Binding フェーズで import { add } は Alias シンボルとして登録されます。

Symbol {
  escapedName: "add",
  flags: Alias,
  declarations: [ImportSpecifier]
}

Alias Symbol は実際の宣言への参照をまだ持っていません。 これは Type Checker が後でで解決します。

Aliasの解決

bar.tsadd(1, "2") の型チェックを行う際、まず add という識別子の型を取得します。checkIdentifier から処理が始まります。Alias Symbol であることが判明すると resolveAlias が呼び出され、次の順番で処理されます。

  1. "./foo" というモジュール指定子を解決し、foo.ts の Module Symbol を取得
  2. その Module Symbol の exports Symbol Table から "add" を検索
  3. 見つかった Symbol を返す

これにより bar.ts の add は foo.ts の addSymbol へと解決され、その型 (a: number, b: number) => number が取得できます。

関数呼び出しの型チェック

add(1, "2")の型チェックは checkCallExpression で行われます。

// 一部省略して抜粋
function checkCallExpression(node: CallExpression): Type {
  // 1. シグネチャを解決
  const signature = getResolvedSignature(node);

  // 2. 各引数の型をチェック
  const returnType = getReturnTypeOfSignature(signature);
  // 3. 返り値の型を返す
  return returnType;
}

シグネチャが解決されると、Checker は各引数の型をチェックします。

  • 引数[0]: 1 -> number(expected: number)-> OK
  • 引数[1]: "2" -> string(expected: number)-> 型エラー

2 番目の引数 "2" は string 型ですが、パラメータ b は number 型を期待しています。この不一致を検出するのが checkTypeAssignableTo です。

string と number には assignable でないため、この関数は false を返し、エラーが報告されます。

error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

ここまで Type Checker が型エラーを出力するまでの一連の流れを見てきました。単純な例を使った型チェックの表層しか触れておらず、型推論にもそこまで踏み込んでいません。根底にある双方向型チェックなどの概念を知るための初めの足がかりとして次の記事はおすすめです。

https://jaked.org/blog/2021-09-07-Reconstructing-TypeScript-part-0

Rustで実装するのは難しいのか

2025 年 3 月 12 日、Microsoft の TypeScript チームが公式に TypeScript Compiler を Go で移植する計画を発表しました。
https://devblogs.microsoft.com/typescript/typescript-native-port

まず大前提として、TypeScript には明確な仕様が存在しないため、コンパイラの挙動それ自体とテストケースが仕様という状態になっています。それにはとにかく細かいケースや複雑なものも含まれるため、既存のコードを Port できれば労力を削減できます。それゆえかチームもそれを選択し、Rewrite ではなく Port というのを強調していました。

ではどの言語で行うかということになると、Rust での Port は言語特性との噛み合わせが悪くかなり難しいと感じました(もちろん rewrite でも難しいですが)。

実装を見た結果、セマンティクスの基盤である Binding 周りの実装で相互参照を多用していたり、AST も可変前提であることがわかりました。
この特性は Rust での実装において大きな障壁となります。相互参照構造を生の参照で表現しようとすると、双方のライフタイムを決定できずまずコンパイルが通りません。

Rc, Arcを使って回避もできますが、それだと次はメモリリークの可能性があります。このトピックに関しては下記の記事が詳しいです。

上記の記事の通り Weak を使ってメモリリークを回避する方法もある一方で、循環データを表現するのは総じて煩雑でパフォーマンス観点でもあまりよくありません。

Binding と Symbol こそが TypeScript の複雑なセマンティクスを担う中核部分です。しかし、言語の制約により 既存実装のデータ構造を保ったままのポートという道が実質断たれています。そのため、単純なポートではなく Rust の言語特性に合わせた再設計が必要になります。[2]
例えば過去に rewrite を試みた stc(archive 済) は出力こそ tsc に似通っていますが、Parsing 以降の実装はかなり別物です。

同じ目的に対してとれる方法は一種類だとは限りません。TypeScript のセマンティクスを維持しつつ Rust に合わせるのも技術的には可能です。しかし、根本的な構造を変えながら膨大な量のテストケースをクリアし、最新の仕様にも追従するのは薔薇の道なので rewrite ではなく port を選択したのにも納得がいくと思います。

参考

TypeScript Compiler の関連資料は全て typescript-compiler-internals にまとめています。

脚注
  1. 書き起こすやるきを出すまでに一年近くかかったので実際には2025年1月頃の話。供養 ↩︎

  2. 例えば stc は id と HashMap を使った方法でそもそも参照を回避して実装していました。oxc も同じ方法を使ったり、bumpalo という arena allocator を使って工夫しています。 ↩︎

Discussion