mini-typescript にobject literal を追加してみた
こんにちは 人材育成室 育成メンバーチームで 研修中の はすと です。
これまで TypeScript コンパイラの教材プロジェクト mini-typescript に let・interface・string literal を追加しながら、コンパイラの各フェーズに触れてきました。今回は object literal(オブジェクトリテラル)の追加 をしてみます。
前回の記事: mini-typescript に string literal を追加して型と値の経路を学ぶ
今回のゴールは、以下のように object literal を扱えるようにすることです。
// 入力 (TS)
interface Point { x: number; y: number };
var p: Point = { x: 1, y: 2 }
// 出力 (JS)
var p = { x: 1, y: 2 }
実は interface 編で interface Point { x: number } のような 型 は定義できるようにしましたが、var p: Point = { x: 1 } のように その型に合う値 を書く手段はありませんでした。{ x: 1 } を式として書けず、parse エラーになっていました。
interface Point { x: number; y: number };
var p: Point = 1 // 型エラーは出る(左 Point、右 number)
var p: Point = { x: 1 } // でもこれは parse エラー。{ x: 1 } を書く手段がない
これは前回の string literal 編で扱った構図と同じです。stringType は最初から定義されていたのに、"hello" という文字列を 値として作る手段が無かった ために、型と値を結びつける場面が成立しませんでした。今回は interface で作った型と、それに合う オブジェクト値 の間で同じことをします。
今回の見どころは、コンパイラがついに 構造的型付け(structural typing) に踏み込むことです。これまで let x: number = 1 のような比較は「同じ numberType を指しているか?」という参照等価で済んでいました。しかし interface 型と object literal 型は別オブジェクトとして毎回生成されるので、参照等価では破綻します。代わりに「プロパティ名とその型が一致するか」を構造的に比較する仕組みを導入することになります。
環境
- Node.js v24.13.0
- pnpm 10.27.0
- TypeScript 6.0.3
- mini-typescript(
string literal追加の続きから)
セットアップは pnpm install && pnpm build だけです。
今回のスコープ
object literal を真面目にやると、メソッド省略記法({ foo() {} })、プロパティ名省略({ x })、スプレッド構文({ ...other })、計算プロパティ名({ [k]: v })まで欲しくなります。今回のゴールは「interface 型に合う値を作って構造的型チェックを通す」ことなので、スコープは最小にします。
- やること:
{ x: 1, y: 2 }の値、ネスト{ outer: { inner: 1 } }、空{}、interface との構造的型互換(プロパティ名と型の完全一致) - やらないこと: メソッド省略記法、プロパティ名省略、スプレッド構文、計算プロパティ名、変数経由の余分プロパティ(部分型)
この線引きで、let・interface・string literal 編と同じ「1機能・コンパイラを縦に通す」粒度に収まります。
本来の TypeScript の挙動と今回の違い
本来の TypeScript は 構造的部分型(structural subtyping) で動いていて、変数を挟むと余分なプロパティを持つオブジェクトでも代入できます。
interface Point { x: number; y: number }
const p = { x: 1, y: 2, z: 3 }
const q: Point = p // OK(変数経由なら通る)
const r: Point = { x: 1, y: 2, z: 3 } // エラー(リテラル直書きには別チェックが入る)
ただし リテラル直書き のときは「過剰プロパティチェック(excess property checks)」という追加の保護機構が乗っていて、余分プロパティをエラーにします。タイポ防止が目的です。普段 TypeScript を書いていて「余分プロパティはエラーになる」と感じるのは、主にこのリテラル直書きの挙動を見ているためです。
今回の mini-typescript は isTypeEqual の要素数比較で 常に余分を弾く 実装にしました。リテラル直書きの挙動とは一致しますが、変数経由のときも弾く点で、本来の TS よりも厳しい完全一致になっています。
object literal を追加する手順
今回触るのは次の4フェーズです。string literal 編と同様、Bind と Transform は触りません。object literal は新しい「宣言」でも「型定義」でもなく、既存の名前空間や transform に影響を与えないからです。
| フェーズ | やること |
|---|---|
| Lex | , を Token.Comma として認識 |
| Parse | ObjectLiteral と PropertyAssignment ノードを組み立て |
| Check | object literal の型推論、構造的型比較 isTypeEqual、interface 側 members の表現を Type に揃える、typeToString 再帰化 |
| Emit | { k: v, k: v } 形式で出力 |
実際に編集するのは、この4フェーズに加えて土台となる types.ts(Token/Node enum と型定義、それから Type の表現 にも手を入れます)です。types.ts はフェーズではなく土台なので表からは外しています。
今回は check.ts に変更が集中 します。フェーズ数は string literal 編と同じ4ですが、構造的型付けの導入で型比較ロジックを根本から書き換えるためです。
① types.ts: Token・Node・Type の拡張
まず全フェーズの土台となる types.ts を整えます。
Token enum に Comma を追加します。
export enum Token {
// ...
OpenBrace,
CloseBrace,
Comma, // 追加
Whitespace,
// ...
}
これまで , は Token.Unknown に落ちていました。interface のメンバ区切りは ; で済んでいたため、カンマは一度も使われていなかったのです。
次に Node enum に ObjectLiteral と PropertyAssignment を追加し、対応する型を定義します。
export enum Node {
// ...
PropertySignature,
ObjectLiteral, // 追加
PropertyAssignment, // 追加
}
export type ObjectLiteral = Location & {
kind: Node.ObjectLiteral
properties: PropertyAssignment[]
}
export type PropertyAssignment = Location & {
kind: Node.PropertyAssignment
name: Identifier
initializer: Expression
}
export type Expression = Identifier | Literal | StringLiteral | ObjectLiteral | Assignment // ObjectLiteral を追加
ここで PropertyAssignment という中間ノードを挟んでいます。素朴に properties: Map<string, Expression> でも値は表現できますが、それだと プロパティごとの位置情報(pos)が消える、AST トラバーサがプロパティ単位で扱えなくなる、将来の拡張(スプレッド構文や省略記法)に対応できない、といった問題が出てきます。1段ノードを挟むことでこれらを担保します。
interface 編で作った PropertySignature と並べると、対称性が見えてきます。
| 名前 | 中身 | |
|---|---|---|
PropertySignature(interface 用) |
Identifier |
typename: Identifier(型を指す) |
PropertyAssignment(object literal 用) |
Identifier |
initializer: Expression(値を作る式) |
string literal 編で意識した「型と値は別経路」が、プロパティのレベルでも現れます。型側ノードと値側ノードが別々に存在しているのです。
最後に Type の表現 を変えます。これは見た目より重要な変更です。
export type Type = { id: string, members?: Map<string, Type> } // Table から Map<string, Type> に変更
interface 編で導入した Type.members は元々 Map<string, Symbol>(= Table 型のエイリアス)でした。しかし、これまで Type.members の 「値」は一度も読まれていません。使われていたのは typeToString の中で members.keys() でキー名を並べるだけ。値が Symbol だろうと何だろうと動作には影響しませんでした。
今回 object literal を入れると、初めて「プロパティの型同士を比較する」必要が出てきます。比較したいのは Type なのに、members に Symbol(宣言の入れ物)が入っていると、毎回 Symbol から Type を取り出す手間が増えます。最初から Type 形式で持つように揃えるのが筋です。
なぜ今まで Table(Map<string, Symbol>)で済んでいたか
interface 編まで、Type.members の値(Symbol)は typeToString でキー名を並べる以外に使われていませんでした。比較は t !== i の参照等価で済んでいて、members の中身を覗く場面が存在しなかったのです。
var p: I = 1 のような場面を考えると分かりやすいです。左辺の Type は { id: "I", members }、右辺は numberType。参照が違うので !== は true、エラーになる。これだけで正しく動きました。
object literal が入ると初めて「両側とも members を持つ別オブジェクト同士の比較」が発生します。参照等価では構造に関わらず常にエラーになってしまうので、members の中身を見て構造的に比較する必要が出てきます。そのとき初めて「値が Symbol か Type か」が問題になります。
Symbol = 「名前 → 宣言の集合」、Type = 「型の構造」。元々は別の層のものを Table に流用していたのが、構造的比較の導入で破綻したのです。
② lex.ts: カンマの字句解析
switch 文に1行追加するだけです。
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
case ",": token = Token.Comma; break // 追加
default: token = Token.Unknown; break
}
動作確認: src/test.ts の lexTests に1件追加します。
"objectLiteralLex": "{ x: 1, y: 2 }",
pnpm test -- --write
ベースラインを見ると、カンマと中括弧が新しいトークンとして並んでいるのが分かります。
[
["OpenBrace"],
["Identifier", "x"],
["Colon"],
["Literal", "1"],
["Comma"],
["Identifier", "y"],
["Colon"],
["Literal", "2"],
["CloseBrace"]
]
③ parse.ts: ObjectLiteral ノードの組み立て
parseIdentifierOrLiteral() に { 始まりの分岐を追加します。
else if (tryParseToken(Token.OpenBrace)) {
const properties: PropertyAssignment[] = []
while (!tryParseToken(Token.CloseBrace)) {
properties.push(parsePropertyAssignment())
tryParseToken(Token.Comma)
}
return { kind: Node.ObjectLiteral, properties, pos }
}
構造は interface 宣言のメンバ解析と同じパターンです。interface 編で書いた parseStatement 内の interface ブロックを並べて見ます。
// 既存(interface のメンバ解析、参考のため再掲)
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 }
}
今回の object literal のループと比べると、違いは2か所だけです。
parseProperty()→parsePropertyAssignment()(プロパティを解析する関数の差し替え)tryParseToken(Token.Semicolon)→tryParseToken(Token.Comma)(区切り文字をカンマに)
ループ構造そのもの(「閉じ括弧が来るまで要素を push し、末尾で区切り文字を消費する」)は同じです。
次に parsePropertyAssignment を定義します。これも interface 用の parseProperty とほぼ同型です。並べて見ます。
// 既存(interface 用)
function parseProperty(): PropertySignature {
const pos = lexer.pos()
const name = parseIdentifier()
parseExpected(Token.Colon)
const typename = parseIdentifier()
return { kind: Node.PropertySignature, name, typename, pos }
}
// 今回追加(object literal 用)
function parsePropertyAssignment(): PropertyAssignment {
const pos = lexer.pos()
const name = parseIdentifier()
parseExpected(Token.Colon)
const initializer = parseExpression() // ← parseProperty は parseIdentifier()
return { kind: Node.PropertyAssignment, name, initializer, pos }
}
唯一の違いは コロンの右側を何で受けるか です。
parseProperty(interface 用):parseIdentifier()で受ける。プロパティの 型名 はnumberやstringのような Identifier 1個だけparsePropertyAssignment(object literal 用):parseExpression()で受ける。プロパティの 値 はリテラル、識別子、別の object literal など Expression として書けるもの何でも
parseExpression() を使うことで、ネストした object literal({ x: { y: 1 } })も自動で動きます。再帰下降パーサらしい嬉しい挙動です。
動作確認: tests/objectLiteral.ts を作成してテストします。
var point = { x: 1, y: 2 }
pnpm test -- --write
tree ベースラインに ObjectLiteral と PropertyAssignment が階層的に並びます。
{
"kind": "Var",
"name": { "kind": "Identifier", "text": "point" },
"init": {
"kind": "ObjectLiteral",
"properties": {
"0": {
"kind": "PropertyAssignment",
"name": { "kind": "Identifier", "text": "x" },
"initializer": { "kind": "Literal", "value": 1 }
},
"1": {
"kind": "PropertyAssignment",
"name": { "kind": "Identifier", "text": "y" },
"initializer": { "kind": "Literal", "value": 2 }
}
}
}
}
init が ObjectLiteral になり、その下に PropertyAssignment が並ぶ階層ができました。initializer には Literal が入っていて、これがネストの場合は ObjectLiteral になります。次はこのノードに型を付けます。
④ check.ts: 構造的型付けの導入
今回の核心です。5つの変更が連動します。順を追って見ていきます。
4-1. ObjectLiteral 推論を追加
checkExpression の switch に Node.ObjectLiteral の case を追加します。
case Node.ObjectLiteral:
const members = new Map<string, Type>()
for (const p of expression.properties) {
members.set(p.name.text, checkExpression(p.initializer))
}
return { id: "object", members }
各 property の initializer を checkExpression で評価して、Map に詰めるだけ。これで { x: 1, y: 2 } の型は { id: "object", members: Map { "x" → numberType, "y" → numberType } } になります。
ここで興味深いのは、毎回 { id: "object", members } という新しいオブジェクトを作っている ことです。check.ts の他のリテラル系(Literal、StringLiteral)と並べると、その異質さが分かります。
case Node.Literal: return numberType // ← ファイル先頭で定義済みの 1 個を使い回す
case Node.StringLiteral: return stringType // ← 同上
case Node.ObjectLiteral: return { id: "object", members } // ← 毎回新しく作る
numberType と stringType は、check.ts の冒頭でこのように定義されています。
const stringType: Type = { id: "string" }
const numberType: Type = { id: "number" }
ファイルに 1 個だけ 作って、プログラム実行中ずっとその 1 個を使い回すオブジェクトのことを singleton(シングルトン) と呼びます。checkExpression(Literal(1)) と checkExpression(Literal(2)) はどちらも numberType を返しますが、これは「同じ numberType という1つのオブジェクトを2回参照している」状態です。
一方、ObjectLiteral 型は singleton にできません。中身となる members がコード次第で毎回違うからです。{ x: 1 } の members は { x → numberType }、{ x: 1, y: 2 } の members は { x → numberType, y → numberType }、{ name: "hi" } の members は { name → stringType } のように、object literal の書き方ごとに別物になります。「すべての ObjectLiteral 型を表す 1 個の Type」を事前に用意しておくことができないので、case の中で毎回新規生成するしかないのです。
これは interface 型でも同じです。checkType の中で return { id: name.text, members } と毎回新規生成しているのを思い出してください。複合型(members を持つ型)は singleton にできない。これがあとで isTypeEqual を導入する必然性につながります。「同じ構造を持つ別オブジェクト同士の比較」が発生するのは、複合型を singleton にできないためです。
4-2. interface 側 members を Type に揃える
interface の Type を作る箇所を、Type.members の新しい表現(Map<string, Type>)に合わせて書き換えます。
const interfaces = ...
if (interfaces.length) {
const members = new Map<string, Type>()
for (const i of interfaces) {
for (const m of i.members) {
members.set(m.name.text, checkType(m.typename)) // checkType で Type に解決して詰める
}
}
return { id: name.text, members }
}
旧コードは members.set(name, { declarations: [m], valueDeclaration: undefined }) のように Symbol を詰めていました。新コードは checkType(m.typename) で「number という文字列名」を解決して numberType を詰めます。
これで interface 側と object literal 側で、members に詰めるものが 同じ Type 形式 に揃いました。あとは中身を比較するだけです。
なお、自己参照する interface(interface I { self: I })はこの実装だと無限再帰になります。今回はスコープ外としています。
4-3. typeToString の再帰化
エラーメッセージを改善します。
function typeToString(type: Type): string {
if (type.members) {
return `{ ${[...type.members].map(([k, t]) => `${k}: ${typeToString(t)}`).join(", ")} }`
}
return type.id
}
旧コードは members.keys() を並べるだけだったので、{ x: number, y: number } 型は { x, y } と表示されていました。新コードでは各メンバの Type を再帰的に文字列化するので、{ x: number, y: number } と表示されます。
なぜこれが大事か。次に作る isTypeEqual でプロパティの型を比較するようになると、{ x: number, y: string } と { x: number, y: number } のような キーは同じだが型が違う ケースが出てきます。キー名だけ並べるとエラーメッセージで { x, y } to { x, y } のような 見た目が同じ 出力になり、読み手に「型レベル比較が動いていること」が伝わりません。再帰化することで、何がどう違うのかがメッセージに現れます。
戻り値の型注釈 : string を明示しているのは、TypeScript の再帰関数の型推論を成立させるためです。
4-4. isTypeEqual(構造的比較)
これが今回最大の見せ場です。型比較を参照等価から構造的比較に置き換えます。
function isTypeEqual(a: Type, b: Type): boolean {
if (a === b) return true
if (a === errorType || b === errorType) return true
if (!a.members || !b.members) return false
if (a.members.size !== b.members.size) return false
for (const [name, aMember] of a.members) {
const bMember = b.members.get(name)
if (!bMember) return false
if (!isTypeEqual(aMember, bMember)) return false
}
return true
}
判定を順に見ます。
a === b→ true: singleton ヒット。numberType同士など。早期 return で高速化- どちらかが
errorType→ true: エラー伝播抑止。一度エラーが出たらそれ以上エラーを増やさないため、「等しい」と見なす - 片方だけ members 不在 → false: プリミティブ型と複合型は別物
size不一致 → false: 余分キー検出の唯一の関門。{ x, y, z }と{ x, y }をここで弾く- 各キーで再帰: キー存在 + 値の型一致を両方確認
ここで重要なのは「なぜ今まで参照等価で済んでいたのか」と「なぜそれが破綻するのか」です。
これまで let x: number = 1 のような場面では:
- 右辺
1の型 =checkExpression(Literal(1))=numberTypeの singleton 参照 - 左辺
numberの型 =checkType("number")= 同じnumberTypeの singleton 参照 !==で false → エラーなし
primitive Type は singleton(ファイル先頭で1個固定)なので、何度引いても同じ参照が返り、参照等価で正しく比較できていました。
interface 型は singleton ではなく毎回 { id, members } を新規生成しますが、interface 編まで interface 型同士を比較する場面が無かった ので問題化しませんでした。var p: I = 1 のような場面は「interface 型」と「primitive 型」の比較で、別オブジェクトなのが 正解(型が違うのでエラーが正しい)でした。
object literal が入ると、初めて var p: I = { x: 1 } のような書き方ができるようになります。左辺は interface Type(新規生成)、右辺は object Type(新規生成)、どちらも members を持つ別オブジェクトです。参照等価でやると 構造が一致していても常にエラー になってしまいます。だから構造を見る比較に進化する必要があるのです。
isTypeEqual の中で errorType を「等しい」と返しているのは少し気持ち悪いですが、これは旧コードの && t !== errorType が担っていた「エラー伝播抑止」を関数の内側に取り込んでいるものです。呼び出し側が errorType を意識しなくて済むぶん、責務が整理されています。
4-5. 代入チェック2か所の置き換え
最後に、isTypeEqual を実際に呼び出す側を書き換えます。
// Var/Let の代入チェック
// 旧: if (t !== i && t !== errorType)
if (!isTypeEqual(t, i))
error(statement.init.pos, `Cannot assign initialiser of type '${typeToString(i)}' to variable with declared type '${typeToString(t)}'.`)
// Assignment の代入チェック
// 旧: if (t !== v)
if (!isTypeEqual(t, v))
error(expression.value.pos, `Cannot assign value of type '${typeToString(v)}' to variable of type '${typeToString(t)}'.`)
errorType のハンドリングが isTypeEqual 側に移ったので、呼び出しは「等しくなければエラー」だけに集中できる形になりました。
動作確認: 構造的型互換が成立する場合と、3パターンの不一致を確認します。
// tests/objectLiteralWithInterface.ts
interface Point { x: number; y: number };
var p: Point = { x: 1, y: 2 } // エラーなし
// tests/objectLiteralMissingKey.ts
interface Point { x: number; y: number };
var p: Point = { x: 1 } // キー欠落エラー
// tests/objectLiteralWrongType.ts
interface Point { x: number; y: number };
var p: Point = { x: 1, y: "two" } // プロパティ型不一致エラー
// tests/objectLiteralExtraKey.ts
interface Point { x: number; y: number };
var p: Point = { x: 1, y: 2, z: 3 } // 余分キーエラー
pnpm test -- --write
objectLiteralWithInterface.errors.baseline は空(エラーなし):
[]
不一致パターンのエラーメッセージは:
// objectLiteralMissingKey
"Cannot assign initialiser of type '{ x: number }' to variable with declared type '{ x: number, y: number }'."
// objectLiteralWrongType
"Cannot assign initialiser of type '{ x: number, y: string }' to variable with declared type '{ x: number, y: number }'."
// objectLiteralExtraKey
"Cannot assign initialiser of type '{ x: number, y: number, z: number }' to variable with declared type '{ x: number, y: number }'."
objectLiteralWrongType のメッセージで、{ x: number, y: string } と { x: number, y: number } のように「プロパティの型レベルで何が違うか」が見えています。typeToString を再帰化した効果です。キー名だけ並べる旧仕様だと { x, y } to { x, y } という出力になって、エラーの正体が分からなくなります。
⑤ emit.ts: オブジェクトリテラル出力
emitExpression の switch に case を追加します。
case Node.ObjectLiteral:
if (expression.properties.length === 0) return "{}"
return `{ ${expression.properties.map(p => `${p.name.text}: ${emitExpression(p.initializer)}`).join(", ")} }`
空 {} のときは早期 return しています。これが無いと { ${...} } がそのまま展開されて "{ }"(スペース2つ)という気持ち悪い出力になります。
中身ありの分岐では properties.map(...) で各プロパティを "key: value" の形に変換し、", " で連結します。p.initializer の emit は emitExpression(p.initializer) の 再帰呼び出し なので、ネスト object literal や string literal、識別子参照などが自動で動きます。
動作確認: 空オブジェクトとネストを確認します。
pnpm test
// emptyObjectLiteral.js
"var empty = {}"
// nestedObjectLiteral.js
"var nested = { outer: { inner: 1 } }"
All tests passed が確認できたら完成です。
テストとベースライン
追加したテストケースをまとめます。
| テストケース | 内容 | 確認内容 |
|---|---|---|
lexTests["objectLiteralLex"] |
{ x: 1, y: 2 } |
カンマと中括弧のトークン化 |
tests/objectLiteral.ts |
var point = { x: 1, y: 2 } |
tree に ObjectLiteral、JS に同形出力 |
tests/emptyObjectLiteral.ts |
var empty = {} |
空 {} が落ちない |
tests/nestedObjectLiteral.ts |
var nested = { outer: { inner: 1 } } |
parseExpression 再帰でネストが動く |
tests/objectLiteralWithInterface.ts |
interface Point + var p: Point = { x: 1, y: 2 } |
構造的型互換(エラーなし) |
tests/objectLiteralMissingKey.ts |
同 interface + { x: 1 } |
キー欠落エラー |
tests/objectLiteralWrongType.ts |
同 interface + { x: 1, y: "two" } |
プロパティ型不一致エラー |
tests/objectLiteralExtraKey.ts |
同 interface + { x: 1, y: 2, z: 3 } |
余分キーエラー(要素数の比較) |
なお、typeToString を再帰化した副作用で、interface 編で作った既存テスト interfaceMemberType.errors.baseline のメッセージも変わります({ x, y } → { x: number, y: number })。これは意図した改善なので、ベースラインを上書きします。
まとめ
object literal の追加を通じて、これまでの編では見えなかったコンパイラの構造が顔を出しました。
interface 編で「型」を、string literal 編で「型と値の経路の分離」を扱ってきましたが、今回はその2つを組み合わせる場面で 構造的型付け に踏み込むことになりました。check.ts の t !== i という参照等価の1行は、これまで何の問題もなく動いていました。primitive Type は singleton で同じ参照が返り、interface 型と primitive 型の比較は別オブジェクトが正解だったからです。しかし object literal が入った瞬間、「同じ構造を持つ別オブジェクト同士の比較」が初めて発生し、!== では一気に破綻します。普段 TypeScript を書いているとき何気なく通っている var p: Point = { x: 1, y: 2 } の型チェックが、コンパイラ内部では「比較の仕組みを参照等価から構造的比較に書き換える」ことを要求している、というのを体感できる場面でした。
もう一つ気付きがあったのは、Type.members の表現を Map<string, Symbol> から Map<string, Type> に変えたことです(types.ts の型定義と、check.ts の interface 側 members 構築の2か所)。これまで members の 「値」は読まれていませんでした。typeToString で keys() を並べるだけだったので、Symbol だろうと Type だろうと動作には影響しなかったのです。構造的比較を入れた瞬間に値を読む必要が出てきて、「Symbol(宣言の入れ物)」と「Type(型の構造)」という別の層を同じ Table に押し込んでいたのが顕在化しました。普段意識しない「層の分離」がコードに現れる瞬間でした。
次の練習問題の候補としては、関数宣言と関数呼び出し が候補にあります。呼び出し箇所での引数型チェックは、今回作った構造的比較がそのまま使えます。また、自己参照する interface(interface I { self: I })対応や、部分型チェック(余分プロパティ許容)への拡張も、今回のコードを発展させる課題として面白そうです。




