JavaやC#といった名前的型付け(Nominal Typing)の言語からTypeScriptに移行したエンジニアが、最初につまずき、そして夜中に冷や汗をかく瞬間がある。
「なぜ、この型エラーにならないんだ?」
「構造が似ているだけで、まったく別ドメインのオブジェクトが代入できてしまった……」
コードレビューをしていると、この「構造的部分型(Structural Subtyping)」の特性を理解しきれていないがために、意図しないバグの温床を作ってしまっているコードに遭遇することが少なくない。
今回は、TypeScriptのコンパイラが裏側でどのように型の互換性を評価しているのか、そのメカニズムを解剖し、実務のフロントエンド開発やAPI連携において「堅牢なドメインモデルをどう構築すべきか」をテクニカルリードの視点から伝授しよう。
—
1. 構造的部分型(ダックタイピング)の正体とコンパイラの思考
TypeScriptは、ランタイムだけでなくコンパイル時においても「構造(Shape)」を重視する。Javaのように「クラス名やインターフェース名が明示的にインプリメントされているか」ではなく、「そのオブジェクトが、要求されたプロパティと型をすべて満たしているか」だけで互換性が判定される。
まずは、以下のコードを見てほしい。
interface User {
id: string;
name: string;
}
interface Product {
id: string;
name: string;
}
function printEntity(entity: User) {
console: console.log(`User: ${entity.name}`);
}
const product: Product = {
id: “p-001”,
name: “MacBook Pro”
};
// 互換性チェック:ProductはUserが要求する構造を満たしているため、コンパイルエラーにならない
printEntity(product); // ⚠️ 意図しないバグの温床
コンパイラは `printEntity` に渡された `product` を評価する際、「`User` という名前がついているか」は一切見ていない。「`id`(string)と `name`(string)を持っているか?」だけを確認する。これがダックタイピングのTypeScriptにおける姿だ。
「もし歩き、鳴き声がアヒルなら、それはアヒルだ」——しかし、フロントエンドのビジネスロジックにおいて、`Product`(商品)を `User`(ユーザー)として処理してしまう関数に渡すことが「正しい」ケースなど存在しない。
—
2. 「余剰プロパティチェック」という名の諸刃の剣
ここで多くの開発者が混乱するのが、変数に直接オブジェクトリテラルを代入する場合の挙動だ。
// コンパイルエラーになる
const user: User = {
id: “u-001”,
name: “John Doe”,
age: 30 // ⚠️ エラー: Object literal may only specify known properties, and ‘age’ does not exist in type ‘User’.
};
「あれ? 構造的部分型なら、余分な `age` があっても通るはずでは?」と思ったかもしれない。
これは 余剰プロパティチェック(Excess Property Checking) と呼ばれる、オブジェクトリテラルに特化したコンパイラの安全装置だ。未入力のタイポや不要なプロパティの混入を防ぐためのものだが、「変数経由」にするとこのチェックはバイパスされる。
const rawData = {
id: “u-001”,
name: “John Doe”,
age: 30,
token: “secret…”
};
// 変数を経由した途端、余剰プロパティチェックは消滅し、構造的部分型のみで判定される
const user: User = rawData; // ✅ エラーにならない!
APIから返ってきた巨大なJSONレスポンスをそのままドメインモデルの型アサーションや代入に使っていると、意図せず機密情報や不要なプロパティを持ったオブジェクトがレイヤー間を汚染していく。これが実務で最も恐ろしいバグの引き金になる。
—
3. 実務で「名前的型付け」をエミュレートする設計パターン
では、構造的部分型の緩さを受け入れつつ、厳密なドメイン境界を引くにはどうすればいいのか。
フロントエンドのコンポーネント設計やAPIクライアント層で使える、実践的な3つのアプローチを紹介する。
パターンA:ブランド型(Branded Types / Nominal Typing Hack)
最も確実な方法は、コンパイラに「名前(あるいはタグ)」を強制することだ。型エイリアスと一意のシンボルを組み合わせることで、構造が同じでも代入不可能な「ブランド型」を作り出せる。
// — プリミティブの迷子を防ぐブランド型 —
declare const __brand: unique symbol;
type Brand
type UserId = Brand
type ProductId = Brand
// コンストラクタ関数で型を保証
function createUserId(id: string): UserId {
return id as UserId;
}
function createProductId(id: string): ProductId {
return id as ProductId;
}
const userId = createUserId(“u-123”);
const productId = createProductId(“u-123”);
function getUser(id: UserId) {
// …
}
getUser(userId); // ✅ 合法
getUser(productId); // ❌ コンパイルエラー! (ProductIdはUserIdではない)
この手法は、IDなどのプリミティブ型が関数の引数内でごちゃ混ぜになるバグを、コンパイル時に100%封殺できる。
パターンB: Interfaceの拡張とプライベートプロパティの活用
クラスベースや、オブジェクト構造の意図せぬ混同を防ぎたい場合は、ダミーのプライベートメンバを持たせるか、`readonly` タグを付与する。
interface User {
readonly _type: “User”; // 構造に「タグ」を強制する
id: string;
name: string;
}
interface Product {
readonly _type: “Product”; // 構造に「タグ」を強制する
id: string;
name: string;
}
function processUser(user: User) {
// …
}
const product: Product = {
_type: “Product”,
id: “p-1”,
name: “Book”
};
// processUser(product);
// ❌ コンパイルエラー: ‘_type’ の型が “Product” と “User” で互換性がない
タグプロパティを入れるだけで、ダックタイピングの「緩さ」を意図的に「厳格さ」へとシフトさせることができる。
—
4. パフォーマンスとコンパイル速度への影響
アーキテクトとして見落としていけないのが、型システムの複雑化がもたらすTypeScript言語サーバー(tsserver)のパフォーマンス低下だ。
構造的部分型のチェックは、コンパイラにとって非常に重い処理になり得る。特に、以下のようなコードは型エイリアスとインターフェースが複雑に交差し、IDEの補完速度(Red squiggleが表示されるまでの時間)を著しく悪化させる。
- 深度の深すぎるユーティリティ型のネスト
- あらゆる場所での `Record
` や過剰なオプショナルプロパティ(`?`)の乱用
チーフアーキテクトからの指針
1. APIレスポンス境界では Zod や Valibot を採用せよ
TypeScriptの型は実行時には消える。外部APIの境界(Network Boundary)では、構造的部分型に頼るのではなく、ランタイムのバリデーションライブラリ(Zod等)を用いてバリデーションと同時に型を推論(`z.infer`)させよ。これにより、型と実行時データの乖離を完全に防げる。
2. ドメインモデルのインターフェースには「意図のタグ」を刻め
単なるデータ構造の使い回しを避け、ビジネス上の意味が異なるのであれば、たとえ構造が同じであっても型を分離し、ブランド型を活用して意図せぬ代入を防ぐこと。
—
まとめ
TypeScriptの構造的部分型は強力な武器である一方、設計の意図を曖昧にする諸刃の剣でもある。
「動くからいいや」で書かれたコードは、プロジェクトがスケールした瞬間にリファクタリング地獄を引き起こす。
コンパイラの裏側の挙動を深く理解し、「機械的な構造の一致」ではなく「ドメインとしての意味の一致」を型で担保すること。それこそが、プロダクションコードの品質を極限まで高める唯一の道である。