TypeScript Compilerの裏側をちょっとだけ理解する
背景
2025年5月に東京で開催されるTSKaigiに30分のセッションとしてプロポーザルを提出しました。
しかし、結果は残念ながら落選し、登壇することはできませんでした。
ですが、プロポーザルを提出するにあたって、TypeScript について整理していた内容や調査していた事がたくさんあり、それをそのままお蔵入りさせるのはもったいないと感じました。そのため、発表内容をできるだけそのまま記事にし、公開しようと思い当たりました。
記事として文字起こしを行うと少し分量として多くなってしまうかもしれないのですが、TypeScript の経験が少ない初学者や、TypeScript を何となくで使用している方に向けて、裏側のコンパイラーについて興味を持つキッカケになれればなと思います。
記事について
- 元々発表するつもりだったスライドも添付しています
- 添付画像だけをさっと見進めても(ある程度は)理解できる形式にしております
- 初学者の方でも理解しやすいように、できるだけ難しい単語などは使用しないよう書いております
- 詳しい方からすると、説明不足・言葉が適切でないということが発生しそうな気がしています🙇
- 記事のゴールは「裏側を何となく理解している」を目指しています
疑問点の共有
この記事を読んでくださっている方は、普段から TypeScript をさまざまな環境で使用して、開発を行っていると思います。TypeScript によって、型安全なコードを書きながら、型推論や型チェックの恩恵を受けている方も多いと思います。
しかし、(自分を含め)TypeScript の型チェックやコンパイルの仕組みについて深く考えることなく、エラーが出たら対処するという形で開発を進めている方も同じように多いと思います。
また、TypeScript が様々な環境で開発され、それが実行されるまでに JavaScript へ変換されていることは広く知られている思います。ですが、「では具体的にどのように変換が行われているのですか?」という所に踏み込んで答えられる人は(自分を含め)多くないと感じました。
そのため、TypeScript Compiler の裏側を図解し、TypeScript から JavaScript への変換がどのような過程を経て行われているのかを説明したいと思います。
前提知識の共有
まず、この記事の大前提となる TypeScript Compiler について説明したいと思います。
TypeScript Compiler は、ざっくり説明すると、「TypeScript のソースコードを JavaScript に変換するためのツール」になります。具体的には、以下の2つの役割を担っています。
-
TypeScript を JavaScript に変換
TypeScript はブラウザや JavaScript エンジンで直接実行できないため、TypeScript コードを標準的な JavaScript へ変換(コンパイル)します。TypeScript のすべての型や構文上の機能が JavaScript に変換された後、どんな環境でも実行できるようにするためです。 -
型チェック
コード内の型の不一致や構文エラーを変換(コンパイル)時に検出し、開発者に警告します。これにより、実行時エラーのリスクを減らし、より信頼性の高いコードを書くことができます。なお、型チェックと変換は別々に行うことも可能です。
このように、TypeScript で書かれたコードを各実行環境に適した JavaScript に変換しつつ、静的型チェックによってバグの早期発見を助けてくれるのが TypeScript Compiler であり、TypeScript 開発において欠かせない存在となっています。
全体像
次に、TypeScript Compiler の全体像をについてざっくり説明したいと思います。
TypeScript Compiler は、実装者が行ったソースコードを受け取り、以下の6つの工程を経て、JavaScript へ変換が行われていきます。
- コード解析(Scanner)
コードを文字単位で読み取り、意味のある最小の単位(トークン)へ分解します - ASTの生成(Parser)
トークンをもとに、構文の規則に従って抽象構文木(AST)を構築します - データの整理(Binder)
識別子とそれに対応する宣言を結びつけ、スコープや参照関係を解決します - 型の検証(Checker)
AST とスコープ情報をもとに型推論と型チェックを行い、型エラーを検出します - 構文の変換(Transform)
AST を必要に応じて変換し、最終的な JavaScript の形式に合わせて調整します - JSへの変換(Emitter)
変換されたの AST をもとに、最終的な JavaScript のコードを出力します
ここでは、ざっくり全体観のみ把握してもらえたらと思います。
それぞれの工程に付けられている名前(Scanner, Parser...)に沿って、後続で詳細に説明を行います。そのため、この全体観は、説明の中で現在どの内容の説明を行っているのかわからなくなった時に、今何を行っている箇所の説明かを振り返ってもらえればと思います。
Scanner
TypeScript Compiler はまず最初に、実装者が作成したソースコードの解析から行います(一般的に字句解析と呼ばれるものです)。
具体的に何を行っているかというと、「TypeScript のソースコードを Token という単位に分解する役割」を持っています。生成される Token について以下のようになります。
Token
コンパイルする上で必要な最小単位の基礎となるデータを生成する役割を持っております。
基礎になるデータの内容としては、値の種類、値、解析を行なっている位置の3つの情報を持ち、後続の処理を行うために必要な値の提供を行っています。
実装例を用いて、行っている内容を解説したいと思います。
const msg: string = "Hello World"
このような実装があった場合、このコードをそれぞれ Scanner で定義されている値の種類毎に分解し、値と位置を後続の処理に提供します。具体的には、以下のように分解されます。
ここでは、重複する Token の分類(主にスペース)は省略していますが、意味のあるコードの分類毎に切り分け、後続処理を行うためのコードの整理を行なっています。
値の種類 | 値 | 解析位置 |
---|---|---|
ConstKeyword | const | 0 |
WhitespaceTrivia | " " | 5 |
Identifier | msg | 6 |
ColorToken | : | 9 |
StringKeyword | string | 11 |
EqualsToken | = | 18 |
StringLiteral | Hello World | 20 |
このように、TypeScript Compiler の最初の工程として、ソースコードを Token という最小単位のデータを生成し、後続に提供する役割を担っております。
Parser
Scanner によってソースコードをトークンに分解した後、Parser の工程でトークンをもとに AST(抽象構文木) を生成します(一般的に構文解析と呼ばれるものです)。
AST
プログラムの構文を木構造(ツリー構造)で表したものになります。
ソースコード(今回の場合だとToken)の要素間の構造の整理を行い、ここで生成される AST を用いて後続の型チェックやコード変換などを行います。
先程と同じ実装例を用いて、処理の流れを解説します。
const msg: string = "Hello World"
この実装例が前段の Scanner によって Token に分割されます。
そして、Parser の処理によって、以下のような AST が生成されます。
- VariableStatement
- VariableDeclarationList
- VariableDeclaration
- Identifier
- StringKeyword
- StringLiteral
それぞれの内容について解説します。
VariableStatement
変数宣言全体を表す。1つ以上の変数を宣言する文を表します。管理している情報としては、const msg: string = "Hello World"
でコードのひとまとまりを管理します。
VariableDeclarationList
1つ以上の変数宣言をまとめたリストを表します。複数の変数宣言があまりイメージ湧きにくいかと思いますが、const x = 1, y = 2
のような定義を行った場合、子階層のVariableDeclarationを複数持つことになります。
VariableDeclaration
具体的な変数の宣言を表します。管理している情報としては、msg: string = "Hello World"
で変数宣言を除いた変数の具体的な内容を管理します。
Identifier
変数名や関数名、クラス名などの識別子を表します。管理している情報としては、msg
の部分になります。
StringKeyword
型アノテーションとして指定されたキーワードを表します、管理している情報としては、string
の部分になります。
StringLiteral
文字列を表します、保持している情報としては、Hello World
の部分になります。
このように、Parser は AST を生成し、コード内の要素同士の構造を整理します。
また、AST には 識別子・型情報・修飾子・制御構造(if 文や for 文など)・位置情報など、後続の処理に必要なさまざまなデータが含まれています。
これにより、型チェック・コード変換・最適化・実行 などの処理がスムーズに行われます。
Binder
次に、Parser の工程で生成された AST を用いて、スコープ情報の整理やシンボルの解決(変数や関数の宣言と参照の対応付け)を行い、後続の型チェックやコード変換をスムーズに行うための基盤を提供します。
Parser で生成される AST では、あくまでコード単体の構造のみを管理しているため、変数や関数がどのスコープに属するのか、どの宣言がどの参照と対応するのかといった情報を保持していません。そのため、ファイル全体を見たときに動作が正しく行われるようスコープ情報の整理や、シンボルの解決を行う必要があります。
以下の実装例を用いて、処理の流れを解説します。
const msg: string = “Hello World”
const welcome = (str: string) => {
console.log(str);
}
const greet = (name: string) => {
const msg = "こんにちわ、世界"
console.log(`${msg}, ${name}! `);
};
このコードに対して、Parser によって生成される AST は以下のようになります。
少し分量が多いですが、コードを要素毎に分解し、ツリー構造で管理していることを知っていると、何となく全体観は把握できるかなと思います。
実装から生成されるAST
- VariableStatement (const msg: string = "Hello World")
- VariableDeclarationList
- VariableDeclaration (msg: string = "Hello World")
- Identifier (msg)
- StringKeyword (string)
- StringLiteral (Hello World)
- VariableStatement (const welcome = (str: string) => { console.log(str) })
- VariableDeclarationList
- VariableDeclaration (welcome = (str: string) => { console.log(str) })
- Identifier (welcome)
- ArrowFunction ((str: string) => { console.log(str) })
- Parameter (str: string)
- Identifier (str)
- StringKeyword (string)
- EqualsGreaterThanToken (=>)
- Block ({ console.log(str) })
- ExpressionStatement
- CallExpression
- PropertyAccessExpression (console.log)
- Identifier (str)
- VariableStatement (const greet = (name: string) => { console.log(`${msg}, ${name}! `) })
- VariableDeclarationList
- VariableDeclaration (greet = (name: string) => { console.log(`${msg}, ${name}! `) })
- Identifier (greet)
- ArrowFunction ((name: string) => { console.log(`${msg}, ${name}! `) })
- Parameter (name: string)
- Identifier (name)
- StringKeyword (string)
- EqualsGreaterThanToken (=>)
- Block ({ console.log(`${msg}, ${name}! `) })
- VariableStatement (const msg = "こんにちわ、世界")
- VariableDeclarationList
- VariableDeclaration (msg = "こんにちわ、世界")
- Identifier (msg)
- StringLiteral (string)
- ExpressionStatement (console.log(`${msg}, ${name}!`))
- CallExpression
- PropertyAccessExpression (console.log)
- Identifier
- Identifier
- TemplateExpression (`${msg}, ${name}!`)
- TemplateHead
- TemplateSpan
- Identifier (msg)
- TemplateMiddle
- TemplateSpan
- Identifier (name)
- TemplateTail
繰り返しになりますが、ここで生成された AST は、スコープ情報やシンボルの解決(変数や関数の宣言と参照の対応付け)が行われていません。
そのため、それぞれ変数・関数宣言が行われているmsg
welcome
greet
の宣言や、関数の引数で受け取っているstr
name
の値が、どこで定義されたものかといったスコープ情報は含まれていません。
もう少し実装に沿った言い方をすると、greet
関数の中で使用されているmsg
が、グローバルスコープのmsg
を参照しているのか、greet
関数内で定義しているmsg
を指しているのかといった情報は、AST だけでは判別できません。
別の例で考えてみましょう。
const stringOrNumber = (x: string | number) => {
if (typeof x === "string") {
x // string
} else {
x // number
}
}
このような実装を行なっていた時、TypeScript ではx
の値は使用されるスコープによって型情報が変化します。しかし、こういった同じ識別子であっても、スコープによって型が異なるような処理は、AST だけでは正しく扱うことができません。
このように、AST ではコードの構文的な構造は把握できますが、変数や関数がどのスコープに属しているか、どの識別子がどの宣言と結びついているかといった「意味的なつながり」までは把握できません。
そのため、Binder でスコープの情報の整理を行うことで、後続の型チェックで型安全性が担保されるために必要なデータを AST に追加する処理を行います。
Checker
次に、Parser で生成された構文構造(AST)と、Binder によって整理されたスコープ・シンボル情報をもとに、Checker が型の整合性を検証します。また、型推論や型の互換性の判定を行い、誤った型の使用があった場合のエラーの検出までを行います。
これにより、開発者はコンパイル時に型の誤りを検出でき、より安全なコードを記述できます。
型推論
以下のような明示的に型が指定されていない場合であっても、自動的に適切な型の推論を行います。
行っていることはシンプルであり、変数が初期化された値の解析を行い、その型をその変数の型として定義を行っています。
const msg = "Hello World" // "Hello World"は文字列 -> msgの型はstringだ!
少し複雑な実装だと、ジェネリクス型の実装があります。
ジェネリクスの実装の場合は、関数の宣言時ではなく、呼び出し時に推論が行われます。
以下の例の場合だと、関数(setup
)の呼び出し時に渡された引数の型をもとに、型引数T
が推論されます。そのため、setup("Hello World")
の場合、"Hello World"
の型がstring
であるため、T
はstring
と推論され、戻り値の型もstring
になります。
function setup<T>(config: T): T {
return config;
}
const abc = setup("Hello World") // 引数に文字列が渡された -> setup関数の`T`はstringだ!
型検証
以下のように、定義されている型と値が一致しているかどうかを判定し、型安全性を確保するための型の整合性が取れているかを検証します。
const str: string = "Hello World" // ⭕️ string型に"Hello World"は代入できる
const num: number = "Hello World" // ❌ number型に"Hello World"は代入できない
具体的に内部でどのようなことを行っているかというと、全ての要素に対応する検証の関数を定義するというシンプルな構造になっています。以下のように、変数や式が指定された型と適切に一致しているかを順番に確認を行い、型の整合性が取れていない場合はエラーが発生します。
// 値の定義🔴 型の定義🔵
checker.checkSourceElementWorker // ソースコード内の要素(変数や式など)を確認
.checkVariableStatement // 変数宣言に関連する型情報を検証
.checkGrammarVariableDeclarationList
.checkGrammarVariableDeclaration // 各変数宣言における型を検証
.checkVariableLikeDeclaration // 変数や定数、プロパティなどが型に従っているかを検証
.checkTypeAssignableToAndOptionallyElaborate // 型が指定された型に代入可能かどうかを検証
.isTypeRelatedTo(🔴, 🔵). // 型間の関連性を確認し、互換性があるか検証
エラー検出
型検証の結果から、型の整合性が取れていない場合のエラーの発生を行います。
具体的には、以下のように多くの場面で遭遇し、型と値の整合性の不一致を検出します。
- 型不一致: 型が一致しない場合
- 代入不可な型: 型同士が互換性がない場合
- 型関連性エラー: 型間の互換性がない場合(特に継承関係)
- 型推論エラー: 型が自動的に推論される場合に予期しない結果が得られる場合
- 未定義のプロパティへのアクセス: 存在しないプロパティにアクセスしようとした場合
- 関数の引数の型エラー: 関数の引数に渡す型が期待と異なる場合
- 戻り値の型エラー: 関数の戻り値の型が定義と異なる場合
- etc...
そして、検出したエラーは、コンパイラやIDEに表示され、開発者がエラーを修正できるようにフィードバックを提供します。このように、Checker によって TypeScript の型チェックが行われ、型安全性を担保する機能を果たしてくれています。
Transform
次に、Checker を通して型のチェックが行われた AST に対して、構文や構造を JavaScript の仕様に沿って書き換える処理を行います(tsconfig.json
の設定を用いる)。これは、ターゲットとなる JavaScript のバージョンやモジュール形式、React JSX などの構文に対応するためのステップになります。
具体的には、以下のような変換が挙げられます。
- JavaScript のバージョンに合わせた変換(ES6, ES2020...)
- モジュールに合わせた変換(commonjs, es2015...)
- React で書かれたコードを JavaScript へ変換
変換前 | 変換後 |
---|---|
let x = 1; | var x = 1; |
const sum = (a, b) => a + b; | var sum = function (a, b) { return a + b; }; |
<Button size="large">Click</Button> | React.createElement(Button, { size: "large" }, "Click") |
ここでは、あくまでわかりやすい変換例を挙げましたが、この他にも tsconfig.json 内で設定された細かなコードの変換処理が行われています。
また、この Transform の中で、TypeScript で記述された型の情報は削除されます。
型の削除を行う方法はシンプルで、AST の中で管理されている型の情報の削除を行うことで、JavaScript への変換を実現しています。
let msg: string = "Hello World";
// 変更前のAST
- VariableStatement
- VariableDeclarationList
- VariableDeclaration
- Identifier
- StringKeyword // 型定義
- StringLiteral
// 変更後のAST
- VariableStatement
- VariableDeclarationList
- VariableDeclaration
- Identifier
- // 型情報の箇所を削除
- StringLiteral
Checker を通過した場合、型定義は役割を果たしたことになるので、この Transform で TypeScript から JavaScript への変換を行なってくれます。
Emitter
最後に、 前段の Transform で変換された AST をもとに、最終的な JavaScript のコードや型定義ファイル(.d.ts)を文字列として出力する処理を行います。
行われる処理としては、大きく分けて以下の2つになります。
- JavaScript ファイルの作成
Transform フェーズで構文変換された AST をもとに、実際の JavaScript コードを文字列として生成します。この生成された JavaScript は、各環境(ブラウザ、JavaScript エンジン...)で問題なく動作が行われる。 - 型定義ファイルの作成
tsconfig.json で型定義ファイルの出力が設定(declaration: true
)されている場合、型定義ファイル(.d.ts)の生成が行われます。この型定義ファイルの生成は、ライブラリなどの型情報を外部に公開する際に重要なファイルになります。
こうして、最終的に各環境で使用される JavaScript のコードが生成されます。
簡単なコード
ここまで、TypeScript Compiler の処理の流れを見てきました。
そして、記事の最初に挙げていた 「TypeScript が裏側でどのようなことを行い、結果として JavaScript に変換がされているのか」という疑問が少し解決されたのではないでしょうか。
ですが、もう少しコンパイラーの中身(コード)の部分に興味を持った方もいるのではないかと思います。そうした方に向けて、TypeScript Compiler の開発に携わっている方が公開されている mini-typescript という TypeScript Compiler の学習を目的とした簡易なコンパイラがあります。
そして、その mini-typescript のコードを今回の記事に沿う形に整理し、各実装に日本語で説明を加えたリポジトリを公開してみました。もしコードをさっと読み流したい方がいれば、ぜひ覗いてみていただけると嬉しいです。(もとの mini-typescript のコードに変更を加えたりはしていません。)
このリポジトリもTSKaigiで使いたくて用意していたのですが、お披露目の場がなくなってしまったため、ここで少し無理やり紹介をさせていただきました🙇
Goへの移植
次に、TypeScript Compiler の実装コードを TypeScript (セルフホスト)から、Go へ移植することが発表されました。そして、この Go への移植により、この記事の中で説明させていただいた TypeScript から JavaScript への変換(コンパイル)を実行する速度が10倍に上がりました。
この Go への移植の背景やセルフホスト時の実装であった課題などは、別の方が挙げられている記事で参照する方が理解が早いと思います。他の方の記事で、移行の背景や TypeScript 利用者への影響、Go 言語が選ばれた理由など、詳細にわかりやすく説明がされていたので参照させていただきます。
結論、今までは TypeScript で TypeScript Compiler が実装されていた状態から、Go で TypeScript Compiler が実装されるようになります。そのため、ここまで説明してきたコンパイルの流れ自体は基本的には変わらず、実行が Go で行われるようになります。(「移植」という言葉でいうと、コンパイルの流れに変更がないのは当たり前ではあるのですが、、、)
以下、公式が出している Go で書かれたコンパイラのリポジトリになるのですが、この記事で紹介した各フェーズの名前の記載の確認できます。興味のある方は、中身を覗いてみると良いと思います。
すでに多くのエンジニアの方が発信されている通り、移植によって TypeScript 自体に何か変更が起こるわけではなく、コンパイルの速度の向上によって今まで以上の開発体験を得ることできます。
この発表の当初、かなりエンジニア界隈で盛り上がりましたが、シンプルにコンパイル速度が向上するのは嬉しいですね(無理やり記事の項目に組み込みましたが、あまり言うことない🥲)
まとめ
ここまで TypeScript Compiler の処理がどのように実行され、どのように TypeScript のコードが JavaScript に変換されるのかを紹介させていただきました。今回のこの記事を通して、少しでも TypeScript の裏側について理解を深めてくださった方がいれば嬉しく思います。
また、ここまで長々と TypeScript Compiler について記事を書いてきましたが、ざっくり理解してもらえるよう言葉が抽象的だったり、説明を一部省いている箇所などもあったりします。できるだけ公式が出している資料やコードを参考にしてまとめてきましたが、間違っている箇所や誤解を生むような文章があれば優しく指摘いただけると嬉しいです。
最後に、自分が TypeScript Compiler を調べるキッカケになった TSKaigi を紹介させていただきます。
2025年5月23,24日に TypeScript をテーマとした技術カンファレンスが開催されます。冒頭に書かせていただいたとおり自分は登壇できなかったのですが、各セッション有名で強いエンジニアの有意義な話が聞けると思います。
自分は現地参加のチケットも買い逃してオンライン参加なのですが、この記事含め少しでも興味を持っていただけたら嬉しいです。
参考
(↑この記事を読まなくても、この動画見れば全部わかる🥲)
Discussion