🔍

TypeScriptコンパイラの読み方

2021/10/31に公開

TypeScriptコンパイラリーディングをする上で、目当てのコードに辿りつくまでの手間を短縮するためのメモ書きです。コードリーディングの一般論や、TypeScriptコンパイラから読み取れる個別事象については極力省略しています。

TypeScriptの主要な処理系

多くのJavaScriptパーサーが拡張としてTypeScriptを読めるようになっています。また抽象構文木のフォーマットに事実上の標準があり、各パーサーはそれに従っています。AST Explorerでこれらのパーサーの出力を調べることができます。特に重要なのが以下の2つの処理系です。

  • TypeScript
    • TypeScriptの型推論・リント・トランスパイル・モジュールバンドリング等ができる。
  • Babel
    • TypeScriptのトランスパイルができる。

TypeScriptコンパイラの構成

libに標準ライブラリ (型定義ファイル) があり、 srcにコンパイラの実装があります。TypeScriptのプログラミング言語としての挙動を調べたいときは大抵src/compiler以下を調べることになります。

  • src/compiler/parser.ts
    • 名前の通り、パーサーです。
    • パーサーのほとんどの処理はこの1ファイルにまとまっています。
  • src/compiler/checker.ts
    • 名前の通り、型検査器・型推論器です。
    • 型検査のほとんどの処理はこの1ファイルにまとまっています。
  • src/compiler/program.ts
    • ファイルごとの設定の処理や警告・エラーの収集などはここに書かれています。
  • src/compiler/utilities.ts, src/compiler/utilitiesPublic.ts
    • checker.tsが使う処理の一部はここに書かれています。
    • ASTのノード名などを手がかりに処理を追うときはこれらのファイルも検索対象に含めておくと取りこぼしが少なくなります。
  • src/compiler/types.ts
    • ビットフラグの定数値やノードの型などがここで管理されています。
  • src/compiler/transformers.ts
    • トランスパイル処理です。

パーサーの読み方

TypeScriptコンパイラのパーサーはJavaScriptモードでも型アノテーション等をパースします (パース後のバリデーションでエラーにします)。ただし、構文的曖昧性などの理由で、わずかにモードに依存するコードがあります。これは contextFlags の利用位置を検索すればわかります。

パーサーの設計は典型的な手書きの再帰下降パーサーです。 parse<構文要素名> のような名前の関数を呼び出すことでパースを行います。 parse<構文要素名>parse<構文要素名>Worker のラッパーになっていることもあります。必要な入力を全て得たら factory.create<ノード名>() でノードを作成して返します。

特に重要な、コードリーディングの起点となる関数として以下があります。

  • parseType
    • number | string などをパースする。
  • parseExpression
    • 1 + 1 などをパースする。
  • parseStatement
    • if (debug) console.log(42); などをパースする。
  • parseSourceFile
    • ファイル全体のパーサー。
  • parseJSDocComment
    • /** ... */ の中にあるタグをパースする。

TypeScriptのパーサーはかなり面倒なことをしている箇所 (構文断片の先読み、トークンの読み直し、文の再解釈など) がありますが、個々のテクニックには本稿では立ち入りません (本稿はあくまで目的のコードを探すまでの案内であるため)。

型検査器の読み方

型検査器を実装する上では Type と TypeNode の区別を意識することがまず重要です。多くのプログラミング言語が「構文木を構文木のまま簡約する」形で処理を行わないように、多くの型検査器も「構文木」と「それが表す型そのもの (=型レベル計算における値)」を区別します。

  • TypeNode は型をあらわす抽象構文木のノードです。
  • Type は型です。

一般論としては、同じTypeをTypeNodeであらわす方法は複数あるかもしれませんし、ひとつもないかもしれません (Rustのクロージャー型など)。ただし、TypeScriptでは型推論結果をもとに .d.ts を復元する仕組みがあるため、TypeをTypeNodeに戻す関数もあります。それぞれの処理はコンパイラ内の以下の関数で行われています。

  • getTypeFromTypeNode: TypeNodeType にする。
  • typeToTypeNode: TypeTypeNode にする。

TypeScriptでは型の簡約が可能な箇所がいくつかありますが、その評価順序はまちまちで一言では言えません。

  • ほとんどは保守的に解決されます (= 書いた型ができるだけ保持される)。
  • Unionの平坦化や重複除去など、自明な処理は一部積極的に解決されます。

そのため、 T[K]keyof T など明らかに計算式っぽい型も Type の一種としてそのまま表現できるようになっています。

型検査器を読む上で混乱しがちな点のひとつが ObjectType の役割です。これは基本的には { foo: number } のようないわゆるリテラル型 (オブジェクトリテラル型) を指しますが、以下のようなものも含まれています。

  • 一見オブジェクトリテラル型には見えないもの
    • 関数型 (x: number) => void
      • これは { (x: number): void } (call signatureのみからなるリテラル型) と同じ意味であることに注意
    • コンストラクタ型 new (x: number) => Number
      • これは { new (x: number): Number } (construct signatureのみからなるリテラル型) と同じ意味であることに注意
    • interface, class (配列 T[] = Array<T> も含む)
  • オブジェクトリテラルとは異なるがObjectTypeと扱われるもの
    • Type Reference。たとえば NonNullable<number | null> など名前で指定される型。
    • タプル型 [number, string]
    • Mapped types { readonly [K in T]: T[K] }
      • Readonly<number> など、プリミティブ型に解決されることもある点に注意
    • Reverse mapped types (mapped typesの型推論で発生する内部的な型)
    • Evolving array types (ループ等制御フロー中でpushされる配列の型推論のために発生する内部的な型)

ObjectTypeになる構文の多くは遅延評価されるためコード中での追跡が大変です。 resolveStructuredTypeMembersinstantiateType を起点に読み進めるといいでしょう。

Babel parserとTypeScript

@babel/parser本体がTypeScriptパーサーを実装しています。@babel/plugin-syntax-typescript があるため一見するとプラグイン機構になっているかのように見えますが、これはトランスパイラパイプライン内で @babel/parser のTypeScriptオプションを有効化するだけの単純な仕組みで、TypeScriptパーサーを実装しているわけではありません。

BabelのTypeScript対応

TypeScript機能のうち、ECMAScript標準や提案として存在するものはBabelの各プラグインで処理されます。そうではないTypeScriptの独自機能が @babel/plugin-transform-typescript で処理されます。 @babel/plugin-transform-typescript がやっていることはそれほど多くない (一通り眺めるだけならすぐできる) ので、ここでは深入りしません。

Discussion