TypeScriptの基礎 - 型の互換性と構造的部分型
概要
TypeScriptにおける型の互換性とは、ある型の値を別の型として使用できるかどうかを判断する仕組みである。
TypeScriptはこの判断を行う時に、型の名前ではなく型の構造 (プロパティや形状) に基づく構造的型付け (Structural Typing) を採用している。
構造的型付けにより、明示的な継承関係がなくても、同じ構造を持つ型は互換として扱われる。
この特性はDuck Typingの考え方に近く、同じように振る舞うならば同じ型として扱う という設計思想を型システムのレベルで実現したものである。
TypeScript 5.0以降では、ジェネリック型パラメータの推論や制御フロー解析が改善され、型チェックの精度が向上している。
TypeScript 5.5ではinferred type predicatesが導入され、TypeScript 5.7ではstrictモードの利用が強く推奨されるようになった。
TypeScript 6.0 (ブリッジリリース) では strict: true がデフォルトで有効になり、型安全性がより強制されるようになる。
また、TypeScript 7.0 (Project Corsa) ではコンパイラがGo言語で書き直され、型チェックが最大10倍高速化される予定である (77.8秒から7.5秒への改善が報告されている)。
構造的型付け (Structural Typing)
基本概念
構造的型付けとは、型の互換性を型の名前ではなく型の構造 (プロパティの形状) に基づいて判定する型システムである。
下表に、構造的型付けに関連する主要な概念を示す。
| 概念 | 説明 |
|---|---|
| 構造的型付け (Structural Typing) | 型の名前ではなく、プロパティや形状に基づいて型の互換性を判定する方式 |
| Duck Typing | 「アヒルのように歩きアヒルのように鳴くならばアヒルである」という考え方。ランタイムで動的に型を判定する |
| 名目的型付け (Nominal Typing) | 型の名前や明示的な継承関係に基づいて互換性を判定する方式 (Java, C# など) |
| 過剰プロパティチェック (Excess Property Check) | オブジェクトリテラルを直接代入する際に、定義外のプロパティを検出する追加チェック |
| Freshness | オブジェクトリテラルが持つ「新鮮さ」の状態。変数に代入されると失われ、過剰プロパティチェックが適用されなくなる |
| Branded Types | プリミティブ型に仮想的なブランドプロパティを付加することで名目的型付けを模倣するパターン |
Duck Typingはランタイムで動作するのに対し、構造的型付けはコンパイル時に型の整合性を検証する点が異なる。
TypeScriptの構造的型付けにより、インターフェースや型エイリアスの名前が異なっていても、同じ構造を持つ型は互換として扱われる。
構造的部分型の仕組み
構造的部分型の基本ルールは、XがYに互換である場合、YはXと同じかそれ以上のメンバーを持つ必要がある というものである。
すなわち、ターゲット型のプロパティを全て持つ型であれば、名前が異なっていても互換として扱われる。
以下に、構造的部分型の基本的な動作を示す。
以下の例では、Bird と Plane は名前が異なるが、同じプロパティを持つため互換として扱われる。
interface Bird {
name: string;
fly(): void;
}
interface Plane {
name: string;
fly(): void;
}
let bird: Bird = { name: "Sparrow", fly: () => {} };
let plane: Plane = bird; // OK - 構造が同じなので互換
型の互換性ルール
オブジェクト型の互換性
オブジェクト型の互換性では、ソース型がターゲット型の全てのプロパティを持つ場合に互換として判定される。
ソース型に余分なプロパティが存在していても、ターゲット型に必要なプロパティを全て含んでいれば互換となる。
interface Target {
name: string;
age: number;
}
interface Source {
name: string;
age: number;
email: string; // 余分なプロパティ
}
let source: Source = { name: "Alice", age: 30, email: "alice@example.com" };
let target: Target = source; // OK : SourceはTargetの全プロパティを持つ
関数型の互換性
関数型の互換性は、パラメータ型と戻り値の型それぞれで異なるルールが適用される。
戻り値の型については共変性 (Covariance) が適用される。
派生型を返す関数は、基底型を返す関数の代わりとして使用できる。
パラメータ型については反変性 (Contravariance) が適用される。
基底型を受け取る関数は、派生型を受け取る関数の代わりとして使用できる。
--strictFunctionTypes オプションを有効にすると、パラメータの反変性チェックが厳格に適用される。
class Animal {}
class Dog extends Animal {}
let animalHandler: (animal: Animal) => void = (animal) => {};
let dogHandler: (dog: Dog) => void = (dog) => {};
dogHandler = animalHandler; // OK : 反変性 - Animalを受け取る関数は、Dogを受け取る関数の代わりになれる
// animalHandler = dogHandler; // ERROR (--strictFunctionTypes 有効時) : 共変ではない
クラスの互換性
クラスの互換性は基本的にオブジェクト型と同様に構造的に判定されるが、private および protected メンバーには特別なルールが適用される。
private または protected メンバーを持つクラスは、同じ宣言元から派生したクラスとのみ互換として扱われる。
異なるクラス定義に同名の private メンバーが存在していても、宣言元が異なるため互換にはならない。
class Animal {
private name: string = "Animal";
}
class Cat {
private name: string = "Cat"; // 同じ構造だが宣言元が異なる
}
class Kitten extends Cat {}
let animal: Animal = new Animal();
let cat: Cat = new Cat();
let kitten: Kitten = new Kitten();
// cat = animal; // ERROR : privateメンバーの宣言元が異なるため互換でない
cat = kitten; // OK : KittenはCatを継承しているため互換
静的プロパティおよびコンストラクタはクラスの互換性チェックの対象外となる。
過剰プロパティチェック (Excess Property Check)
オブジェクトリテラルに対する厳密なチェック
TypeScriptは、オブジェクトリテラルが変数に直接代入される場合、または関数の引数として直接渡される場合に、過剰プロパティチェックを適用する。
このチェックにより、型定義に存在しないプロパティがオブジェクトリテラルに含まれていると、コンパイルエラーが発生する。
過剰プロパティチェックの主な目的は、タイプミスや意図しないプロパティの追加を早期に検出することである。
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): void { /* ... */ }
// 直接代入 - ERROR : 'colour'はSquareConfigに存在しない (colorのタイプミス)
// createSquare({ colour: "red", width: 100 });
// 正しいプロパティ名 : OK
createSquare({ color: "red", width: 100 });
変数経由での代入との違い
オブジェクトリテラルを1度変数に代入してから渡す場合、Freshnessが失われるため過剰プロパティチェックが適用されない。
このため、型定義に存在しないプロパティが含まれていてもコンパイルエラーにはならない。
下表に、過剰プロパティチェックが適用される条件とされない条件を示す。
| 代入方法 | チェック適用 | 例 |
|---|---|---|
| オブジェクトリテラルの直接代入 | 適用される | 型アサーションを使用しない直接代入 |
| オブジェクトリテラルを関数引数として直接渡す | 適用される | fn({ colour: "red" }) |
| 変数に代入後に渡す | 適用されない | let opts = { colour: "red" }; fn(opts); |
| 型アサーションを使用する | 適用されない | fn({ colour: "red" } as SquareConfig) |
| インデックスシグネチャを持つ型への代入 | 適用されない | [propName: string]: any を持つ型 |
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): void { /* ... */ }
// 直接代入 : ERROR
// createSquare({ colour: "red", width: 100 });
// 変数経由 : OK (Freshnessが失われる)
let opts = { colour: "red", width: 100 };
createSquare(opts);
過剰プロパティチェックの回避方法
過剰プロパティチェックを意図的に回避する方法は複数存在する。
それぞれの方法には適切な使用場面がある。
- 変数への代入を経由する方法
- オブジェクトリテラルを一度変数に代入することでFreshnessを失わせ、チェックを回避する。
let opts = { colour: "red", width: 100 }; createSquare(opts); // OK
- 型アサーションを使用する方法
asキーワードを使用して型を明示的に指定することにより、チェックを回避する。createSquare({ colour: "red", width: 100 } as SquareConfig); // OK
- インデックスシグネチャを追加する方法
- 型定義にインデックスシグネチャを追加することで、任意の追加プロパティを許容する。
interface SquareConfig { color?: string; width?: number; [propName: string]: any; // 任意のプロパティを許容 } createSquare({ colour: "red", width: 100 }); // OK
名目的型付けとの違い
名目的型付け (Nominal Typing) とは
名目的型付けとは、型の互換性を型の名前や明示的な継承関係に基づいて判定する型システムである。
Java、C#、C++等の言語がこの方式を採用している。
名目的型付けでは、同じ構造を持つ型であっても名前が異なれば互換として扱われない。
型の互換性を利用するためには、明示的な継承やインターフェース実装が必要となる。
下表に、構造的型付けと名目的型付けの主な違いを示す。
| 項目 | 構造的型付け (TypeScript) | 名目的型付け (Java, C#等) |
|---|---|---|
| 互換性の判定基準 | 型のプロパティ・形状 | 型の名前・継承関係 |
| 明示的な継承 | 不要 | 必要 |
| 同名プロパティを持つ別名の型 | 互換として扱われる | 互換として扱われない。 |
| 柔軟性 | 高い | 低い |
| 意図しない互換性 | 発生しやすい | 発生しにくい。 |
| タイプミスの検出 | 過剰プロパティチェックで補完 | 型名の一致で自動検出 |
TypeScriptで名目的型付けを模倣する方法
TypeScriptは構造的型付けを採用しているが、意図しない型の混用を防ぐために名目的型付けを模倣する手法が存在する。
代表的な手法として、型に一意のブランドプロパティを付加するBranded Typesパターンがある。
このパターンにより、同じ基底型 (例: string や number) を持ちながら意味的に異なる型を区別できる。
Branded Typesパターン
Branded Typesパターンは、基底型に仮想的なブランドプロパティを交差型として付加することで、型の区別を実現する。
基本的な実装例を以下に示す。
type UserId = string & { __brand: "UserId" };
type OrderId = string & { __brand: "OrderId" };
function createUserId(id: string): UserId {
return id as UserId;
}
function createOrderId(id: string): OrderId {
return id as OrderId;
}
let userId: UserId = createUserId("user-001");
let orderId: OrderId = createOrderId("order-001");
// userId = orderId; // ERROR : UserIdとOrderIdは互換でない
unique symbol を使用することにより、より堅牢なBranded Typesを実装できる。
unique symbol は各宣言が一意のシンボル型を持つため、外部から同名のプロパティを偽造することが困難になる。
declare const __brand: unique symbol;
type Brand<T, B> = T & { readonly [__brand]: B };
type UserId = Brand<number, "UserId">;
type OrderId = Brand<number, "OrderId">;
let userId: UserId = 123 as UserId;
let orderId: OrderId = 456 as OrderId;
// userId = orderId; // ERROR : UserId と OrderId は互換でない
バリデーション付きのファクトリ関数と組み合わせることで、実行時の安全性も確保できる。
declare const __brand: unique symbol;
type Brand<T, B> = T & { readonly [__brand]: B };
type UserId = Brand<number, "UserId">;
type EmailAddress = Brand<string, "EmailAddress">;
function createUserId(id: number): UserId {
if (id <= 0) throw new Error("UserId must be positive");
return id as UserId;
}
function createEmailAddress(email: string): EmailAddress {
if (!email.includes("@")) throw new Error("Invalid email");
return email as EmailAddress;
}
const userId = createUserId(42); // UserId
const email = createEmailAddress("a@b.com"); // EmailAddress
実用的な注意点
構造的型付けによる意図しない互換性
構造的型付けの柔軟性は強みである一方、意図しない型の混用が発生するリスクをはらんでいる。
特に、意味的に異なるが構造的に同一なプリミティブ型の場合、コンパイラは区別できない。
以下の例では、UserId と OrderId がともに number の型エイリアスであるため、互換として扱われてしまう。
このような問題に対しては、Branded Typesパターンを使用することで型レベルでの区別が可能になる。
type UserId = number;
type OrderId = number;
function processUser(id: UserId): void { /* ... */ }
let orderId: OrderId = 456;
processUser(orderId); // OK : コンパイルエラーにならない (意図しない混用)
型安全性を高めるためのパターン
構造的型付けの特性を理解した上で、型安全性を高めるための主なパターンを以下に示す。
| パターン | 説明 |
|---|---|
| Branded Typesの使用 | 意味的に異なるプリミティブ型を区別するために、ブランドプロパティを付加する。 ファクトリ関数と組み合わせることで、実行時バリデーションも兼ねることができる。 |
strict: true の有効化 |
TypeScript 6.0以降ではデフォルトで有効になるが、現時点でも明示的に有効にすることを推奨する。strictFunctionTypes が有効になり、関数型の反変性チェックが厳格に適用される。
|
readonly の活用 |
変更を意図しないプロパティには readonly を付加することで、誤った代入を防ぐ。 |
| インターフェースの明示的な設計 | 型の用途が明確になるようにインターフェース名を具体的に命名し、プロパティ構造を適切に分離する。 構造が偶然一致することによる意図しない互換性を最小化できる。 |
関連情報