mini-typescript に let を追加して TypeScript コンパイラの中身を覗いてみた
こんにちは 人材育成室 育成メンバーチームで 研修中の はすと です。
先日 TSKaigi に参加してきたのですが、発表を聞いているうちに技術的な好奇心を刺激されました。気持ちが高まった私は、この機会にTypeScript のコンパイラの流れを学んでみることにしました。
そんな時に見つけたのが、TypeScript チームのメンバーが作った教材プロジェクト mini-typescript です。README に練習問題として「let を追加してみよう」というものが用意されていたので、まずはこれを実装しながら、コンパイラの中を覗いてみることにしました。
最終的に、以下のような変換ができるようになるのがゴールです。
// 入力 (TS) → // 出力 (JS)
let x = 1 var x = 1
一見1行の機能追加ですが、これがコンパイラのほぼ全フェーズに絡みます。
環境
- Node.js v24.13.0
- pnpm 10.27.0
- TypeScript 6.0.3
- mini-typescript(main ブランチ)
セットアップは pnpm install && pnpm build だけです。
mini-typescript とは
手を動かす前に、ざっくり mini-typescript の中身を見ていきます。
mini-typescript は、TypeScript コンパイラの構造を学ぶために作られた教材プロジェクトです。README では "the smallest compiler I can imagine"(想像できる限り最小のコンパイラ)と紹介されていて、その言葉どおり、扱える言語機能はごくわずかです。
var宣言(型注釈あり/なし)typeエイリアス- 数値リテラル、識別子、代入式
- 型は
stringとnumberのみ
この小ささが、コンパイラ全体の構造を見通す手助けになります。src/compile.ts を見てみると、コンパイルパイプラインがそのまま並んでいます。
export function compile(s: string): [Module, Error[], string] {
errors.clear()
const tree = parse(lex(s))
bind(tree)
check(tree)
const js = emit(transform(tree.statements))
return [tree, Array.from(errors.values()), js]
}
ソース文字列を Lex(字句解析)→ Parse(構文解析)→ Bind(シンボルテーブル構築)→ Check(型チェック)→ Transform(AST 変換)→ Emit(JS 出力)の順に通していく流れが、6つの関数としてそのまま並んでいます。実際の TypeScript コンパイラ(tsc)も、大枠ではこの流れを踏んでいます。
コンパイラ用語の補足
- lex(字句解析): ソース文字列を意味のある最小単位(トークン)に分解します。
var x = 1なら[Var, Identifier("x"), Equals, Literal("1")]のようなトークン列を返します。 - parse(構文解析): トークン列を木構造(AST)に組み立てます。「これは変数宣言で、名前は x で、初期値は 1」という階層を作ります。
- AST(Abstract Syntax Tree, 抽象構文木): ソースコードの構造を表現する木。各ノードに種別(kind)と子要素を持ちます。
- bind(シンボルテーブル構築): 「この名前はどの宣言を指すか」のマッピング表(シンボルテーブル)を作ります。
- check(型チェック): シンボルテーブルを引きながら、型の整合性を検査します。
- transform: AST を書き換えます。型注釈を消したり、新しい構文を古い構文に変換したりします。
- emit: AST を最終的な文字列(JS)に出力します。
let を追加する手順
「let を追加する」という一見シンプルな作業ですが、触ることになるのは次の5フェーズです。
| フェーズ | やること |
|---|---|
| Lex | let をキーワードとして認識 |
| Parse | let x = 1 を AST に |
| Bind | 同じ名前の二重宣言(再宣言)を検出 |
| Check | use-before-declaration エラー |
| Transform | let → var に正規化 |
上から順に、一緒に潰していきましょう。
① Lex に let を追加
コンパイラの入り口はレキサー(字句解析)です。let という文字列を「ただの単語」ではなく「キーワード」として認識させる必要があります。
やることは src/lex.ts の keywords マップに1行足すだけ。
const keywords = {
"function": Token.Function,
"var": Token.Var,
"let": Token.Let, // 追加
"type": Token.Type,
"return": Token.Return,
}
合わせて types.ts の Token enum に Let を追加します。
export enum Token {
Function,
Var,
Let, // 追加
Type,
Return,
// ... Equals, Literal, Identifier など続く
}
動作確認はテストで行います。src/test.ts のレキサーテスト(lexTests)に "let x = 1" を入力するケースを追加し、pnpm test を実行すると、lexAll("let x = 1") の結果がベースラインに記録されます。
追加した letLex テストケース(src/test.ts):
呼び出している lexAll の定義(src/lex.ts):
結果
[["Let"], ["Identifier", "x"], ["Equals"], ["Literal", "1"]]
"Let" が先頭に出ていれば、入り口は突破です。次はこれを AST に組み立てます。
ベースラインテスト
期待する出力をファイル(ベースライン)に保存しておき、実行のたびに実際の出力と突き合わせる手法です。スナップショットテストとも呼ばれます。差分が出たときは、その変更が意図どおりかを確認し、問題なければベースラインを更新します。
② Parser で Node.Let を生成
トークン列を受け取ったら、次は構文解析です。let x = 1 という並びを、意味のある木構造(AST)に組み立てます。
var の解析ロジックがすでにあるので、ほぼ同じコードを書けば動きます。
else if (tryParseToken(Token.Let)) {
const name = parseIdentifier()
const typename = tryParseToken(Token.Colon) ? parseIdentifier() : undefined
parseExpected(Token.Equals)
const init = parseExpression()
return { kind: Node.Let, name, typename, init, pos }
}
ここで重要なのは、types.ts の Statement ユニオン型に Let を追加することです。
export type Statement = ExpressionStatement | Var | Let | TypeAlias
あとは、この Let を使う各フェーズで Node.Let を処理していきます。多くは既存の Node.Var のケースに case Node.Let を並べるだけです。たとえば emit は、var と同じ出力でよいので1行足すだけで済みます。
// src/emit.ts
case Node.Var:
case Node.Let: { // 追加
const typestring = statement.typename ? ": " + statement.name : ""
return `var ${statement.name.text}${typestring} = ${emitExpression(statement.init)}`
}
あと let 固有の処理が必要なのは bind・check・transform の3つです。それぞれこれから実装していきます。
今回の手順で実装した箇所
let の解析ロジック(src/parse.ts):
Statement ユニオン型への追加(src/types.ts):
パーサーまで通れば、AST には Node.Let がちゃんと乗っています。次はシンボルテーブルの構築に進みます。
③ Bind で同名宣言(再宣言)を検出
ここからは「同じ名前をどう扱うか」二重宣言を弾くか、値と型をどう区別するかを決めるフェーズです。まず、これから実装する判定で何を期待しているか、先に確認しておきます。
| 入力 | 期待 | 理由 |
|---|---|---|
let x = 1; var x = 2 |
エラー | どちらも値(var と let をまたいでも衝突) |
let x = 1; type x = number |
OK | 値と型は別の名前空間 |
let x = 1; var x = 2 のように同じ名前の値を二重に宣言したら 再宣言エラー(Cannot redeclare)、let x = 1; type x = number のように 名前空間が違う場合は共存OK。これを実現したいのです。ここで「名前空間」という概念が効いてきます。
名前空間 / 宣言空間 (declaration space)
名前空間 / 宣言空間 (declaration space) とは同じ名前でも「値」と「型」では別物として扱われる仕組み。let x = 1(値)と type x = number(型)が同じ x でも衝突しないのはこのためです。
TypeScript 公式ドキュメントでは「値・型・namespace の3つのグループ (groups)」と表現されます(namespace Foo {} というコードをまとめる構文とは別概念)。
本物のコンパイラもこの区別を SymbolFlags(Value / Type / Namespace)として持っており、後で出てくる mini-typescript の Meaning 型はこれを最小限に切り出したものです。
実装では、isValue というヘルパー関数を作って判定を行いました。
const isValue = (k: Node) => k === Node.Var || k === Node.Let
const other = symbol.declarations.find(d =>
isValue(d.kind) === isValue(statement.kind)
)
isValue で衝突は検出できたものの、今度は「let の参照が解決できない」という別の問題にぶつかりました。bind 側の resolve 関数の実装は以下のようになっていました。
export function resolve(locals: Table, name: string, meaning: Node.Var | Node.TypeAlias) {
const symbol = locals.get(name)
if (symbol?.declarations.some(d => d.kind === meaning)) return symbol
return undefined
}
これは Node.Var を渡すと「Var 宣言を探す」という挙動です。
let が無かった時は、値として宣言できるのは var だけでした。そのため、Node.Var を渡すことが「値の名前を引く」ことと同じ意味になり、これで成立していました。
ところが let が入ると、Node.Var を渡しても Let 宣言は見つかりません。「let の参照を resolve しようとしても見つからない」という問題が生まれてしまいました。
そこで、AST 種別と名前空間概念を分離するため、Meaning 型を導入しました。
export type Meaning = 'value' | 'type'
export function resolve(locals: Table, name: string, meaning: Meaning) {
const symbol = locals.get(name)
if (!symbol) return undefined
if (meaning === 'type') {
return symbol.declarations.some(d => d.kind === Node.TypeAlias) ? symbol : undefined
}
return symbol.declarations.some(d => isValue(d.kind)) ? symbol : undefined
}
「value 名前空間を探したい」という意図が、そのままコードで表現できるようになりました。
実はこの meaning 引数は本物の TypeScript コンパイラと同じ発想です。tsc の checker.ts も名前解決の関数が meaning: SymbolFlags(Value / Type / Namespace)を受け取り、「いまどの空間の名前を探しているか」を引数で渡します。mini-typescript の Meaning 型は、その仕組みを 'value' | 'type' の2択まで削ぎ落としたものになっています。
実装した該当箇所はこちらです。
同名宣言(再宣言)の検出(src/bind.ts の bindStatement):
Meaning 版の resolve(src/bind.ts):
Symbol とは何か
ここで言う Symbol は JavaScript 組み込みの Symbol ではなく、mini-typescript 独自に定義された型です。
export type Symbol = {
valueDeclaration: Declaration | undefined
declarations: Declaration[]
}
役割は「ある名前に紐づく宣言情報をまとめる」こと。例えば var x = 1 を bind すると、"x" → Symbol { valueDeclaration: <Var ノード>, declarations: [<Var ノード>] } というマッピングが作られます。
checker が x の参照を見たとき、このシンボルテーブルを引いて「x の宣言はあれだ」と辿り、型を求めます。AST と意味解析の間を繋ぐ橋のような存在です。
名前の衝突と解決が片付きました。次は let ならではの振る舞い、「宣言前に使えない」という制約部分を実装します。
④ Check で use-before-declaration
let には var と決定的に違う性質があります。宣言前の参照を許さない という点です。
目指す振る舞いは以下の通り。
y; // var なら OK、let なら エラー
let y = 1
var は巻き上げで OK、let はエラー。これを checker で再現します。
巻き上げ / TDZ について
巻き上げ (hoisting): var 宣言が実行時にスコープの先頭に持ち上げられたかのように振る舞う JS の挙動。宣言前にアクセスしても undefined が返り、エラーにはなりません。let / const には巻き上げが適用されず、宣言前のアクセスは TDZ エラーになります。
TDZ (Temporal Dead Zone): let / const 宣言が含まれるブロックの先頭から、宣言文の実行完了までの「死の区間」のこと。この区間で当該変数にアクセスすると ReferenceError になります。
checker の Identifier ケースで、位置比較による判定を追加します。
case Node.Identifier: {
const symbol = resolve(module.locals, expression.text, 'value')
if (symbol) {
if (symbol.valueDeclaration!.kind === Node.Let
&& expression.pos < symbol.valueDeclaration!.pos) {
error(expression.pos, `Block-scoped variable '${expression.text}' used before its declaration.`)
}
return checkStatement(symbol.valueDeclaration!)
}
error(expression.pos, "Could not resolve " + expression.text)
return errorType
}
ポイントは expression.pos < symbol.valueDeclaration!.pos の一行です。参照位置が宣言位置より前なら、let の使用前参照エラーと判定できます。
実装した該当箇所はこちらです。
checkExpression の Identifier ケース(src/check.ts):
ここまでで、let の検証はすべて動くようになりました。残りはJSとして吐き出す部分です。
⑤ Transform で let → var
let の検証は通ったので、最後は JS として吐き出します。といっても mini-typescript の出力はもともと var だけで、let 専用の出力経路を持ちません。そこで let を var に揃えてから emit に渡します。これは実際の TypeScript が target: "ES5" で let を var に降ろすのと同じ発想で、その最小版にあたります。
ES2015 / ES5
ES2015 / ES5: ECMAScript の仕様バージョン。ES5(2009 年)は let / const / class などが無い古い仕様、ES2015(ES6, 2015 年)でこれらが追加されました。古いブラウザ(IE11 など)は ES5 までしか動かないため、let を var に変換する「降ろし」が必要になります。
やることはこれだけです。
case Node.Var:
case Node.Let:
return [{ ...statement, kind: Node.Var, typename: undefined }]
^^^^^^^^^^^^^^^^^
kind: Node.Var で上書きするだけ。これで transform 後の AST に Node.Let は存在しなくなり、emit は何も考えず var を出力します。
// 入力 (TS)
let x = 1
// 出力 (JS)
var x = 1
実は mini-typescript の transform は、これまで「型注釈を消す」だけで、本来の役割である AST 構造の書き換えはしていませんでした。今回 kind: Node.Var で上書きすることで、初めて transform が「構造を変える」仕事をしたことになります。
emit 側で let / var を出し分けることも可能ですが、それをやると「let を var に降ろす」という変換ロジックが emit に染み出してしまいます。transform で AST を書き換え、emit はそれをそのまま文字列化する という責務分離を守ることで、後に const を追加するときも同じ場所に書くことができます。
実装した該当箇所はこちら
transformStatement の let → var 変換(src/transform.ts):
まとめ
let の追加をするために、入り口の Lex から出口の Emit まで、一通り学ぶことができました。
- let の追加だけでコンパイラ全フェーズを縦に通る。一機能の実装が、コンパイラ全体構造の見学になる
- 名前空間・宣言空間・責務分離 といった設計概念に自然に触れられる
Nodeの種別と名前空間は別概念だと気づける。Meaning型の導入は、実際のtscのmeaning: SymbolFlagsを最小化したものになっていて、本物の設計に重なる感覚が得られた
なぜ TypeScript のコンパイラが今のような設計になっているのか、その一端を小さく体感できる良い教材でした。
mini-typescript の README には、他にも let の複数宣言、interface、arrow function などの練習問題が並んでいます。普段 TypeScript を書いている方なら、ぜひ次の一問に挑戦してみてください。得られる視点は、普段の TypeScript 利用とはまた別のレイヤーだと思います。私と同じようにコンパイラの中を覗いてみたい方の、最初の一歩になれば嬉しいです。
参考
- mini-typescript - GitHub
- Modern Compiler Implementation in ML — 著者が mini-typescript を書き始めるきっかけになった本
- TypeScript Deep Dive 日本語版 - コントリビュート(ベースライン) — 実際の TypeScript コンパイラでのベースラインテストの仕組み
- TypeScript Handbook - Declaration Merging — 名前空間を「namespace / type / value の3つのグループ」と表現する公式の一次ソース
- TypeScript Deep Dive - Declaration Spaces — 値空間と型空間という「宣言空間」の考え方
- microsoft/TypeScript - checker.ts — 本物の名前解決が使う
meaning: SymbolFlags(Meaning型の元ネタ)






