mini-typescript に interface を追加してみた
こんにちは 人材育成室 育成メンバーチームで 研修中の はすと です。
前回、TypeScript コンパイラの教材プロジェクト mini-typescript に let を追加しながら、コンパイラの全フェーズに触れてみました。Lex から Emit まで、letの追加だけでコンパイラ全体に及ぶのは学びでした。よりTypeScript コンパイラの理解を深めるために今回は interface の追加 をしてみます。
前回の記事: mini-typescript に let を追加してコンパイラの流れを学ぶ
今回のゴールは、以下のように interface を扱えるようにすることです。
// 入力 (TS)
interface I { x: number }
interface I { y: number } // 同名宣言はマージされ { x: number, y: number } になる
var p: I = 1
// 出力 (JS)
var p = 1 // interface 宣言は消える
let の見どころが「宣言前に使えない(use-before-declaration)」だったのに対し、interface の見どころは 宣言マージ(declaration merging) です。同じ名前の interface を複数書くと、エラーにはならずプロパティが合成されます。普段なんとなく使っているこの挙動を、コンパイラの実装として再現していきます。
環境
- Node.js v24.13.0
- pnpm 10.27.0
- TypeScript 6.0.3
- mini-typescript(
let追加の続きから)
セットアップは pnpm install && pnpm build だけです。
今回のスコープ
interface を真面目にやろうと思うと、本来は「オブジェクト型」と「オブジェクトリテラル値({ x: 1 })」「構造的型チェック」まで欲しくなります。が、README の練習問題が求めているのは次の一文です。
Interfaces should have an object type, but that object type should combine the properties from every declaration.
つまり必要なのは オブジェクト「型」(プロパティの一覧を持つ型)であって、オブジェクト「値」ではありません。なので今回のスコープは以下にします。
- やること:
interface宣言、プロパティを持つオブジェクト型、同名宣言のマージ - やらないこと: オブジェクトリテラル値(
{ x: 1 })、構造的代入チェック(別記事に切り出す)
この線引きにより、let 編と同じく「1機能・全フェーズ」の粒度に収まります。
interface を追加する手順
今回の作業は次の6ステップです。Lex から Emit までのコンパイラ各フェーズに加え、途中で「型モデル」にも手を入れます。
| フェーズ | やること |
|---|---|
| Lex | interface キーワードと { } を認識 |
| Parse | interface Name { prop: Type } を AST に |
| 型モデル | Type を「オブジェクト型」へ拡張 |
| Bind | 型名前空間で解決し、同名宣言をマージ |
| Check | 全宣言からプロパティを合成した型を構築 |
| Transform / Emit | interface 宣言を消去 |
上から順に潰していきます。
① Lex に interface と波かっこを追加
入り口のレキサーに、interface をキーワードとして、{ } を記号トークンとして認識させます。
src/lex.ts の keywords マップに1行:
const keywords = {
"function": Token.Function,
"var": Token.Var,
"let": Token.Let,
"type": Token.Type,
"interface": Token.Interface, // 追加
"return": Token.Return,
}
波かっこは scan の switch に追加:
switch (s.charAt(pos - 1)) {
case '=': token = Token.Equals; break
case ';': token = Token.Semicolon; break
case ":": token = Token.Colon; break
case "{": token = Token.OpenBrace; break // 追加
case "}": token = Token.CloseBrace; break // 追加
default: token = Token.Unknown; break
}
合わせて types.ts の enum Token に Interface / OpenBrace / CloseBrace を足します。
export enum Token {
Function,
Var,
Let,
Type,
Interface, // 追加
Return,
Equals,
Literal,
Identifier,
Newline,
Semicolon,
Colon,
OpenBrace, // 追加
CloseBrace, // 追加
Whitespace,
Unknown,
BOF,
EOF,
}
結果(interface I { x: number } のレキサー出力イメージ)
[["Interface"], ["Identifier", "I"], ["OpenBrace"],
["Identifier", "x"], ["Colon"], ["Identifier", "number"], ["CloseBrace"]]
見事に、"Interface" と "OpenBrace" / "CloseBrace" が並んでいます。次はこのトークン列を AST に組み立てます。
② Parser で Node.Interface を生成
トークン列を AST に組み立てます。interface Name { prop: Type; ... } を読み、プロパティの一覧を持つノードを作ります。
まず types.ts にノード種別と型を追加します。
export enum Node {
Identifier,
Literal,
Assignment,
ExpressionStatement,
Var,
Let,
TypeAlias,
Interface, // 追加
PropertySignature, // 追加
}
export type PropertySignature = Location & {
kind: Node.PropertySignature
name: Identifier
typename: Identifier
}
export type Interface = Location & {
kind: Node.Interface
name: Identifier
members: PropertySignature[]
}
export type Statement = ExpressionStatement | Var | Let | TypeAlias | Interface
export type Declaration = Var | Let | TypeAlias | Interface
PropertySignature は、interface の中に書く x: number のようなプロパティ1つを表すノードです。プロパティ名(name = x)と型注釈(typename = number)を持ちます。本物の TypeScript コンパイラでも、同じ役割のノードが PropertySignature と呼ばれています。
Interface ノードは、その PropertySignature を members に配列でまとめて持ちます。interface I { x: number; y: string } なら members は [x のシグネチャ, y のシグネチャ] になります。
パーサ本体(src/parse.ts の parseStatement())。type の分岐の下に追加します。
else if (tryParseToken(Token.Interface)) {
const name = parseIdentifier()
parseExpected(Token.OpenBrace)
const members: PropertySignature[] = []
while (!tryParseToken(Token.CloseBrace)) {
members.push(parseProperty())
tryParseToken(Token.Semicolon)
}
return { kind: Node.Interface, name, members, pos }
}
プロパティ1つを読むヘルパー関数を追加
function parseProperty(): PropertySignature {
const pos = lexer.pos()
const name = parseIdentifier()
parseExpected(Token.Colon)
const typename = parseIdentifier()
return { kind: Node.PropertySignature, name, typename, pos }
}
これで、interface Name { ... } を Node.Interface ノードに組み立てられるようになりました。
次に、空 interface(interface I {})をどう扱うかです。
本物の TypeScript は、メンバが0個の空 interface(interface I {})も許します。これを素直に扱えるよう、メンバの読み取りは CloseBrace が来るまで回す while ループにしました。
while (!tryParseToken(Token.CloseBrace))が肝で、{の直後に}が来たら(空 interface)ループに入らずmembersは[]にする。}を見つけたら同時に消費してループを抜ける。- なので末尾に
parseExpected(Token.CloseBrace)は書かない tryParseToken(Token.Semicolon)で区切りの;を任意扱いに
区切りを緩くしたのにも理由があります。本物の TS は interface のメンバ区切りに ;・,・改行のいずれも許します。ここでは「; があれば消費、なくても次へ」という最小版にしています。
ユニオンに型を足すと switch が連動する
Statement ユニオンに Interface を足した瞬間、check.ts / transform.ts / emit.ts の switch が「Node.Interface を処理していない」とコンパイルエラーになります。これは TypeScript の網羅性チェックが、対応漏れを静的に教えてくれているおかげです。
この段階では各 switch に最小の case を足してビルドを通します。本格的な中身は後続フェーズで肉付けします。
③ 型モデルを「オブジェクト型」へ拡張
ここが今回大きめな設計の変更点です。これまで Type は次のように、組み込み型を文字列 id で表すだけでした。
export type Type = { id: string }
interface が表すのは「プロパティの集まり」なので、メンバを持つオブジェクト型を表現できる必要があります。
export type Type = { id: string, members?: Table }
members の入れ物には、let 編で Symbol.declarations に使ったものと同じ Table(Map<string, Symbol>)を流用します。members? を任意にしている点もポイントで、string / number のような組み込み型は members を持たず(従来どおり id だけ)、interface のオブジェクト型だけが members を持つ、という区別を1つの Type で表せます。
④ Bind で型名前空間に登録し、同名宣言をマージ
interface は type と同じ 型名前空間 に属します。さらに interface 固有の挙動として、同名宣言をエラーにせず合成(マージ) する必要があります。
let 編で導入した再宣言チェックを思い出すと、同じ名前空間の宣言が2回出たらエラーにしていました。
const other = symbol.declarations.find(d =>
isValue(d.kind) === isValue(statement.kind)
)
if (other) {
error(statement.pos, `Cannot redeclare ...`)
}
このままだと interface I {} を2回書いた瞬間に再宣言エラーになり、マージできません。そこで interface 同士だけはこのチェックを免除 し、declarations に積み上げるようにします。
実装は3か所です。
1. 外側の if に Node.Interface を足してbind の対象に入れる
let 編の bindStatement は Var/Let/TypeAlias だけを対象にしてため、
interface も登録されるように条件を足します。これを忘れると locals に interface が入らず、型として引けません。
if (statement.kind === Node.Var
|| statement.kind === Node.Let
|| statement.kind === Node.TypeAlias
|| statement.kind === Node.Interface) {
2. 再宣言チェックを「衝突候補の探索」と「マージ判定」に分ける。
ここで一工夫します。衝突相手を探す find の条件にマージ除外を混ぜると、other が「衝突相手がいるけど、interface マージは除く」という二重の意味になってしまいます。そこで other は本来の「同名前空間の既存宣言」を素直に探すだけにし、マージ判定は isInterfaceMerge という別フラグに切り出します。
// 同じ名前空間にある既存宣言(衝突候補)
const other = symbol.declarations.find(d => isValue(d.kind) === isValue(statement.kind))
// interface 同士は衝突ではなくマージ
const isInterfaceMerge = other?.kind === Node.Interface && statement.kind === Node.Interface
if (other && !isInterfaceMerge) {
error(statement.pos, `Cannot redeclare ${statement.name.text}; first declared at ${other.pos}`)
}
| 入力 | 期待 | 理由 |
|---|---|---|
interface I { x: number } interface I { y: number } |
OK(マージ) | 両方 interface → 除外され push |
type I = number interface I {} |
エラー | 型空間で同名、かつ両方 interface ではない |
interface I {} var I = 1 |
OK | 型と値で名前空間が違う |
3. resolve で interface を型として引けるようにする。
型を引くとき、Node.Interface を Node.TypeAlias と並べて型名前空間として認めます。
if (meaning === 'type') {
return symbol.declarations.some(
d => d.kind === Node.TypeAlias || d.kind === Node.Interface
) ? symbol : undefined
}
この時点で interface I { x: number }; interface I { y: number } を bind すると、tree ベースラインの locals はこうなります。
"locals": {
"I": [
{
"kind": "Interface",
"pos": 9
},
{
"kind": "Interface",
"pos": 36
}
]
}
I という1つの名前に、Interface 宣言が2つ並んで積まれているのが分かります。これがマージの下準備です。実際にこの2つのプロパティを1つの型へ合成するのは、次の Check フェーズです。
⑤ Check で全宣言からプロパティを合成
checker では、interface を参照したときに その名前の全宣言からプロパティを集めて1つのオブジェクト型を作ります。これがマージの「実体」です。
checkType の default(型名を解決する分岐)を書き換えます。解決したシンボルに Interface 宣言があれば、全 interface の members を1つの Table に合成して返します。
default:
const symbol = resolve(module.locals, name.text, 'type')
if (!symbol) {
error(name.pos, "Could not resolve type " + name.text)
return errorType
}
const interfaces = symbol.declarations.filter(d => d.kind === Node.Interface) as Interface[]
if (interfaces.length) {
const members: Table = new Map()
for (const i of interfaces) {
for (const m of i.members) {
members.set(m.name.text, { declarations: [m], valueDeclaration: undefined })
}
}
return { id: name.text, members }
}
return checkType((symbol.declarations.find(d => d.kind === Node.TypeAlias) as TypeAlias).typename)
ここでのポイント:
- 二重
for(宣言ごと × メンバごと)は「全宣言の全プロパティを集める」ための本質的で、これがマージそのもの members.set(プロパティ名, Symbol)で、各プロパティを そのPropertySignatureをdeclarationsに持つ Symbol として登録する(プロパティも「宣言された存在」として本物の構造に寄せる)
ステップ② で case Node.Interface: return { id: statement.name.text } と仮置きしていた checkStatement のケースも、この checkType に一本化します。
case Node.Interface:
return checkType(statement.name)
検証用に、次のコードを通してみます。I は x を持つ宣言と y を持つ宣言に分かれていますが、これがマージされて1つの型になるはずです。
interface I { x: number }
interface I { y: number }
var p: I = 1
実行してみると、代入チェックのエラーメッセージに合成結果が現れます。
Cannot assign initialiser of type 'number' to variable with declared type '{ x, y }'.
エラー文から{ x, y } 2つの宣言の x と y が1つの型に合わさっているのが確認できます。なお number を { x, y } に代入できないというエラー自体は、現状の型比較が参照同一性ベースだからで、これを構造的に判定するのは今回はスコープ外です。ここでは「型が { x, y } に解決されている」ことが確認できれば十分です。
参照同一性ベースとは
型が同じかどうかを「同じオブジェクトか」だけで判定している、ということです。checker の比較はこの1行です。
if (t !== i && t !== errorType)
!== はオブジェクトの参照比較なので、中身ではなく「メモリ上で同じインスタンスか」を見ています。string / number は1個だけ生成して使い回すので、これで正しく判定できます。
ところが interface は解決のたびに新しい { id, members } を作るため、内容が同じでも毎回別インスタンスです。var p: I = 1 も「右辺の number」と「左辺の { x, y }」が別オブジェクトなので t !== i が成立してエラーになります。本来は参照ではなくプロパティを突き合わせる構造的比較が必要です。
宣言マージの検証はこれで完了です。残りは JS として吐き出す部分、出口の Transform / Emit です。
⑥ Transform / Emit で interface を消去
interface は型だけの存在なので、JS 出力では完全に消えます。TypeAlias と同じ扱いです。
// src/transform.ts
case Node.TypeAlias:
case Node.Interface:
return []
この transform の case 自体は、ステップ②でユニオンに Interface を足した時点で網羅性チェックを抜けるため先に書いていました。あわせて emit にも仮の interface ケースを置いていたのですが、ここで一度立ち止まり考えてみます。
transform が先に interface を消すので、emit に interface が渡ってくることは絶対にありません。つまり、emit に置いた interface ケースは到達不能なデッドコードです。ただ switch の網羅性チェックがあるため、ケースを単純に消すとコンパイルエラーになります。
この「消したいのに消せない」状況は、型で表現することで解けました。transform の出力は「実行時に残る文」だけなので、その型を新しく定義します。
// src/types.ts
/** transform 後に実行時へ残る文。let は var に正規化され、type/interface は消える */
export type RuntimeStatement = ExpressionStatement | Var
transform ではこの RuntimeStatement[] を返すようにし、emit ではその狭い型だけを受け取ります。
// src/transform.ts
function transformStatement(statement: Statement): RuntimeStatement[] { ... }
// src/emit.ts
export function emit(statements: RuntimeStatement[]) { ... }
function emitStatement(statement: RuntimeStatement): string {
switch (statement.kind) {
case Node.ExpressionStatement:
return emitExpression(statement.expr)
case Node.Var: {
const typestring = statement.typename ? ": " + statement.name : ""
return `var ${statement.name.text}${typestring} = ${emitExpression(statement.init)}`
}
}
}
これで、emit が受け取る型から Interface・TypeAlias・Let が消えたため、case は「書く必要がない」 = 網羅性を保ったまま削除ができました。「transform で AST から消し、emit はそれを文字列化するだけ」という責務分離が、型のレベルでも表現された形です。
結果として、interface は JS 出力から綺麗に消えます。
// 入力 (TS)
interface I { x: number };
interface I { y: number };
var p: I = 1
// 出力 (JS)
var p = 1
テストとベースライン
tests/ に検証ケースを追加し、pnpm test でツリー・エラー・JS 出力をベースラインに記録しました。
| テストケース | 確認内容 |
|---|---|
singleInterface |
単一 interface のパースと bind |
emptyInterface |
空 interface({})が落ちない |
mergeInterface |
同名 interface が locals に2宣言積まれる |
typeInterfaceConflict |
type と interface の同名は再宣言エラー |
interfaceMemberType |
参照した型が { x, y } に合成される |
ベースライン更新の落とし穴
pnpm test(内部で --write)は 参照ベースラインが存在しないときだけ書き込みます。なのですでにある参照は、出力が変わっても自動では上書きされません。テストケースを書き間違えて一度誤った参照を作ってしまうと、直しても古い参照が残り続けます。その場合は該当の参照ファイルを削除してから再生成するか、local を reference へ反映します。
まとめ
interface の追加を通じて、let 編では踏み込まなかった 型モデルそのものに手を入れることになりました。
interface 固有の面白さは 宣言マージで、「同名は衝突」という値の世界の直感が、型の世界では「合成」に変わるという点です。値の追加(let)と型の追加(interface)では、コンパイラの中で触る場所も悩みどころも違いました。両方を小さく体験すると、「値と型は別の名前空間」「宣言は合成されることがある」といった普段なんとなく使っている TypeScript の挙動が、コンパイラの実装としてどう支えられているのかが見えてきます。次は arrow function あたりに挑戦してみたいと思います。







