TypeScript を書いていると、「別々に定義したはずの型なのに、なぜか代入できてしまう」と感じたことはないでしょうか。これは TypeScript が型の互換性を名前ではなく「形(構造)」で判断しているためです。この仕組みを構造的部分型(Structural Typing)と呼びます。この記事では、構造的部分型とは何かを、名前的部分型(Nominal Typing)との違い、余剰プロパティや過剰プロパティチェックの挙動、関数やクラスの互換性、そして構造的型付けで困る場面を回避する branded types まで、動くコードとあわせて解説します。
目次
構造的部分型とは何か
構造的部分型(Structural Typing)とは、2つの型が互換かどうかを型の名前ではなく、持っているプロパティの形(構造)で判断する仕組みです。「アヒルのように歩き、アヒルのように鳴くならアヒルとみなす」というたとえから、ダックタイピングとも呼ばれます。TypeScript はこの構造的部分型を採用しています。
これと対になる考え方が名前的部分型(Nominal Typing、公称型)です。Java や C# などの言語では、たとえ中身がまったく同じでも、型の名前が違えば互換ではないと判断されます。TypeScript はこの立場を取らず、あくまで構造で比較します。両者の違いを整理すると次のようになります。
| 考え方 | 互換性の判断基準 |
|---|---|
| 構造的部分型(TypeScript) | プロパティの形が合っていれば、型名が違っても互換とみなす |
| 名前的部分型(Java・C# など) | 形が同じでも、宣言された型名が違えば別の型として扱う |
別々に定義した型でも形が同じなら代入できる
まずは基本の例です。名前も継承関係もまったく異なる2つの interface を定義しても、プロパティの形が一致していれば代入し合えます。
// 名前も継承関係も違う、別々の interface
interface Point2D {
x: number;
y: number;
}
interface Coordinate {
x: number;
y: number;
}
const p: Point2D = { x: 1, y: 2 };
// 型名は違うが、構造が同じなので代入できる
const c: Coordinate = p; // OK
console.log(c.x, c.y); // 1 2
Point2D と Coordinate は互いにまったく関係を宣言していませんが、どちらも x: number と y: number を持つという構造が同じなので、TypeScript は互換とみなします。名前的部分型の言語ならエラーになる場面ですが、TypeScript ではこれが正常に通ります。これが構造的部分型のもっとも基本的な振る舞いです。
プロパティが多いほうは少ないほうに代入できる
構造的部分型では、必要なプロパティをすべて満たしていれば、それ以外に余分なプロパティを持っていても代入できます。「求められている形を含んでいるか」だけを見て、余分な部分は無視されるためです。
interface Point2D {
x: number;
y: number;
}
// x, y に加えて label も持つオブジェクト
const labeled = { x: 1, y: 2, label: '原点' };
// 求められる x, y を満たしているので、余分な label があっても代入できる
const p: Point2D = labeled; // OK
console.log(p.x, p.y); // 1 2
labeled は Point2D が要求する x と y を持っているため、余分な label があっても Point2D として扱えます。「多いぶんには構わない、足りないのがダメ」というのが構造的部分型の基本感覚です。ただし、この「余分があってもよい」というルールには、次に説明する例外があります。
オブジェクトリテラルを直接代入すると過剰プロパティチェックが働く
先ほどは変数を経由すれば余分なプロパティがあっても代入できました。ところが、オブジェクトリテラルを型注釈のある変数や引数に直接書いた場合だけは、余分なプロパティがあるとエラーになります。これを過剰プロパティチェック(excess property check)と呼びます。タイプミスなどの間違いを早めに知らせるための、構造的部分型に対する例外的な仕組みです。
interface Point2D {
x: number;
y: number;
}
// エラー: オブジェクトリテラル既知のプロパティのみ指定できます。
// 'label' は型 'Point2D' に存在しません。
const p: Point2D = { x: 1, y: 2, label: '原点' };
// 一度変数に入れてから代入すれば、過剰プロパティチェックは働かない
const tmp = { x: 1, y: 2, label: '原点' };
const q: Point2D = tmp; // OK
同じ中身のオブジェクトでも、リテラルを直接書いた p はエラーになり、いったん変数 tmp に入れてから代入した q は通ります。これは矛盾ではなく、過剰プロパティチェックが「新しく作ったリテラルをその場で代入するとき」に限って追加で行われるからです。リテラルにしか存在しないプロパティは、多くの場合タイプミスか不要な指定なので、その瞬間にだけ厳しくチェックしているわけです。
意図的に追加のプロパティを許したい場合は、型側にインデックスシグネチャ([key: string]: unknown など)を用意するか、いったん変数に代入してから渡す、といった方法で回避できます。
関数の引数と戻り値も構造で比較される
構造的部分型はオブジェクトだけでなく関数にも適用されます。関数どうしの互換性では、引数の数は少ないほうを多いほうに代入できます。要求される関数より引数が少なくても、余った引数を無視して呼べるためです。
// (a: number, b: number) => number を要求する型 type BinaryOp = (a: number, b: number) => number; // 引数が1つだけの関数 const negate = (a: number): number => -a; // 引数が少ないぶんには代入できる(余った b は無視される) const op: BinaryOp = negate; // OK console.log(op(5, 99)); // -5(第2引数は使われない) // 引数が多い関数は代入できない const withThree = (a: number, b: number, c: number): number => a + b + c; // エラー: 型が一致しません(要求より引数が多い) const op2: BinaryOp = withThree;
配列の forEach にインデックスを受け取らないコールバックを渡せるのは、この仕組みのおかげです。戻り値については逆で、戻り値の型はより詳しい(プロパティの多い)ものを、より単純なものへ代入できます。関数を受け取る側が期待する結果を満たしていれば、余分な情報を返すぶんには問題ないためです。
interface Named {
name: string;
}
// name に加えて age も返す関数
const getUser = (): { name: string; age: number } => ({
name: 'Alice',
age: 30,
});
// 戻り値は「より多くを返す関数」を「少しだけ求める型」に代入できる
type NameProvider = () => Named;
const provider: NameProvider = getUser; // OK
console.log(provider().name); // Alice
クラスも名前ではなく構造で比較される
クラスも例外ではありません。extends や implements による明示的な継承関係がなくても、インスタンスの構造(公開されているプロパティとメソッドの形)が一致していれば、TypeScript はそれらを互換とみなします。
class Dog {
constructor(public name: string) {}
bark(): string {
return 'ワン';
}
}
class Robot {
constructor(public name: string) {}
bark(): string {
return 'ビープ';
}
}
// Dog を要求する変数に Robot を代入できる(継承関係はないが構造が同じ)
let pet: Dog = new Robot('R2'); // OK
console.log(pet.name, pet.bark()); // R2 ビープ
Dog と Robot は継承関係を宣言していませんが、どちらも name: string と bark(): string を持つため、TypeScript は互換と判断します。なお、クラスに private や protected なメンバーがある場合は例外で、それらは同じ宣言に由来するときだけ互換になります。private メンバーを持つクラスどうしは、たとえ構造が同じでも別物として扱われます。
構造が同じだと区別できなくて困る場面
構造的部分型は柔軟で便利ですが、「形は同じだが意味は別物」を混同できてしまうという弱点もあります。たとえばユーザー ID と商品 ID を、どちらも string で表しているとします。
type UserId = string;
type ProductId = string;
function getUser(id: UserId) {
console.log('ユーザーを取得:', id);
}
const productId: ProductId = 'prod-123';
// UserId と ProductId は実体がどちらも string なので、
// 取り違えてもエラーにならない(構造が同じため)
getUser(productId); // 意図しないが通ってしまう
type UserId = string のような型エイリアスは新しい型を作るわけではなく、単なる string の別名にすぎません。そのため UserId と ProductId は構造的にまったく同じで、取り違えてもコンパイラは何も言いません。こうした「値の中身は同じだが、意味として混ぜたくない」ケースでは、あえて名前的部分型のように区別したくなります。
branded types で名前的っぽく区別する
この問題を回避する定番のテクニックが branded types(ブランド型、タグ付き型)です。実際には存在しないブランド用のプロパティを型のうえだけで付け足し、構造をわざと異なるものにすることで、TypeScript の構造的比較を使って区別します。
// string に、型のうえだけのブランドを付け足す
type UserId = string & { readonly __brand: 'UserId' };
type ProductId = string & { readonly __brand: 'ProductId' };
// 値をブランド型に変換するヘルパー
const toUserId = (id: string): UserId => id as UserId;
function getUser(id: UserId) {
console.log('ユーザーを取得:', id);
}
const userId = toUserId('user-1');
getUser(userId); // OK
const productId = 'prod-123' as ProductId;
// エラー: 型 'ProductId' の引数を型 'UserId' に割り当てられません。
getUser(productId);
__brand は実際の値には存在せず、型のうえでだけ付いているタグです。'UserId' と 'ProductId' というリテラルの違いによって UserId と ProductId の構造が別物になるため、取り違えがコンパイルエラーとして検出されるようになります。実行時のデータは相変わらず単なる string なので、実行コストはかからず、型チェックのときだけ名前的部分型のように振る舞わせられる、というのがこのテクニックの狙いです。ID やメールアドレス、検証済みの文字列など、「同じ string でも混ぜたくない値」を安全に扱いたいときに役立ちます。
まとめ
TypeScript の型の互換性は、型名が同じかどうか(名前的部分型)ではなく、プロパティの形が合っているかどうか(構造的部分型 / ダックタイピング)で決まります。そのため、別々に定義した interface でも構造が同じなら代入でき、必要なプロパティさえ満たしていれば余分なプロパティがあっても構いません。ただし、オブジェクトリテラルを型付きの変数や引数に直接書いたときだけは過剰プロパティチェックが働き、余分なプロパティがエラーになります。関数は引数の少ないほうを多いほうへ、戻り値は詳しいほうを単純なほうへ代入でき、クラスも private メンバーがない限り構造で比較されます。一方で、意味の異なる値でも構造が同じだと区別できないという弱点があり、その回避策として branded types でブランドを付け足し、名前的部分型のように区別する方法があることも押さえておきましょう。