はじめに
TypeScriptの型システムの核となる「部分型関係」(サブタイピング)について解説します。これはTypeScriptの高度な型付けを理解する上で重要な概念です。
部分型関係とは
部分型関係とは、2つの型の互換性を表す概念です。型Sが型Tの部分型であるとは、S型の値はT型の値として扱えるということです。別の言い方をすると、Sはより多くの制約(プロパティやメソッド)を持つ「上位互換」の型です。
TypeScriptは 構造的部分型(structural subtyping) を採用しています。これは実用プログラミング言語の中では珍しく、オブジェクト型のプロパティを実際に比較して部分型かどうかを決める方式です。Java、C#などの一般的なオブジェクト指向言語では、名前的部分型(nominal subtyping) が採用されており、明示的に「この型はこの型の部分型である」と宣言されたもの(継承など)だけが部分型として認められます。
構造的部分型と名前的部分型の詳細比較
構造的部分型(Structural Subtyping)
TypeScriptが採用している構造的部分型は、**型の構造(持っているプロパティやメソッド)**だけに基づいて型の互換性を判断します。
// 構造的部分型の例
interface Point2D {
x: number;
y: number;
}
interface Point3D {
x: number;
y: number;
z: number;
}
let point2D: Point2D = { x: 0, y: 10 };
let point3D: Point3D = { x: 0, y: 10, z: 20 };
// OK: Point3DはPoint2Dが要求するすべてのプロパティを持っている
point2D = point3D;
// エラー: Point2DはPoint3Dが要求するz propertyを持っていない
// point3D = point2D; // Type '{ x: number; y: number; }' is missing the following properties from type 'Point3D': z
構造的部分型の特徴:
- 型の名前ではなく、その構造(プロパティとメソッド)が重要
- 明示的な継承関係がなくても、必要なプロパティをすべて持っていれば互換性がある
- ダックタイピング(「アヒルのように歩き、アヒルのように鳴くなら、それはアヒルである」)の原則に基づいている
名前的部分型(Nominal Subtyping)
Java、C#などの多くのオブジェクト指向言語が採用している名前的部分型は、型名と明示的な継承関係に基づいて型の互換性を判断します。
同じ例を名前的部分型の言語で考えると:
// Javaでの名前的部分型の例
class Point2D {
public int x;
public int y;
}
class Point3D extends Point2D { // 明示的な継承関係を宣言
public int z;
}
Point2D point2D = new Point2D();
Point3D point3D = new Point3D();
// OK: Point3DはPoint2Dのサブクラスとして明示的に宣言されている
point2D = point3D;
// エラー: Point2DはPoint3Dのサブクラスではない
// point3D = point2D; // 型の不一致エラー
名前的部分型の特徴:
- 型の名前と明示的な継承関係(extends、implements)が重要
- 構造が同じでも、明示的な継承関係がなければ互換性がない
- クラス階層をより厳密に制御できる
どちらが優れているのか?
どちらのアプローチも一長一短があります:
- 構造的部分型:より柔軟で、特にライブラリやフレームワークとの連携がスムーズ。ただし、意図しない型の互換性を許してしまう可能性もある。
- 名前的部分型:より厳格で、型の誤用を防ぎやすい。ただし、柔軟性に欠け、ボイラープレートコードが必要になることがある。
TypeScriptが構造的部分型を採用している理由の一つは、JavaScriptという動的型付け言語の上に静的型システムを構築するという性質上、柔軟性を重視したためと考えられます。
オブジェクト型の部分型関係
基本的なオブジェクト型の部分型関係を見てみましょう:
type HasName = {
name: string;
};
type HasNameAndAge = {
name: string;
age: number;
};
// HasNameAndAgeはHasNameの部分型
const person: HasNameAndAge = { name: "John", age: 30 };
const named: HasName = person; // OK
HasNameAndAge
はHasName
の部分型です。なぜなら、HasNameAndAge
はHasName
が持つすべてのプロパティ(この場合はname: string
)を持っているからです。つまり、HasNameAndAge
型の値はHasName
型の値として常に使用できます。
関数型の部分型関係
関数型の場合、部分型関係はもう少し複雑になります。
1. 戻り値の型による部分型関係(共変性)
関数の戻り値型については、「共変的(covariant)」な部分型関係が成り立ちます:
const fromAge = (age: number): HasNameAndAge => ({
name: "John Smith",
age,
});
// 関数型の互換性: (age: number) => HasNameAndAge は (age: number) => HasName の部分型
const f: (age: number) => HasName = fromAge;
const obj: HasName = f(100);
console.log(obj); // { name: 'John Smith', age: 100 }
この例では、fromAge
関数はHasNameAndAge
型の値を返します。そして、この関数を(age: number) => HasName
型の変数f
に代入しています。これが可能なのは、HasNameAndAge
がHasName
の部分型だからです。
obj
の型はHasName
と宣言されていますが、実行時には{ name: 'John Smith', age: 100 }
というオブジェクトが格納されます。なぜなら、TypeScriptの型チェックは静的に行われるもので、実行時には型情報が消去されるためです。f
とfromAge
は異なる型の変数ですが、その実体は同じ関数オブジェクトなので、age
プロパティも保持したオブジェクトが返されます。
2. void型の特殊な振る舞い
TypeScriptでは、void
型に関して特別なルールがあります:
const f2 = (name: string) => ({ name });
const g: (name: string) => void = f2;
通常、関数の戻り値型の部分型関係では、関数Aの戻り値型が関数Bの戻り値型の部分型である場合のみ、関数A全体を関数B型の変数に代入できます。
しかし、void
型に関しては例外があります:どんな型を返す関数でも、戻り値がvoid
型の関数型の変数に代入できます。
これは「戻り値を使用しない」という意図を表すためのルールです。例えば、イベントリスナーのように、戻り値が無視される文脈で関数を使用する場合に便利です:
button.addEventListener('click', (event) => {
return "clicked"; // 値を返しているが、その値は無視される
});
実用的な意義
部分型関係を理解することで、より柔軟で型安全なコードを書くことができます:
-
より明確なAPIの設計: 必要なプロパティだけを持つインターフェースを定義し、実装側ではより多くの情報を持つオブジェクトを返せます。
-
柔軟な関数の受け渡し: コールバック関数などで、より具体的な処理を行う関数を渡せます。
-
コードの再利用性の向上: 基本的な型を定義し、それを拡張した型を様々な場所で使い回せます。
-
ライブラリとの連携: 構造的部分型により、外部ライブラリのインターフェースに明示的に適合させるコードを書く必要がなく、必要なプロパティやメソッドを持つオブジェクトを渡せば良いため、柔軟な連携が可能です。
TypeScriptの構造的部分型の実践的な使用例
例1: モジュールの差し替え
構造的部分型を利用すると、モックやテスト用の実装を簡単に差し替えられます:
// インターフェース定義
interface UserRepository {
findById(id: string): Promise<User>;
save(user: User): Promise<void>;
}
// 本番用実装
class DatabaseUserRepository implements UserRepository {
async findById(id: string): Promise<User> {
// データベースからユーザーを取得する実装
return /* ... */;
}
async save(user: User): Promise<void> {
// データベースにユーザーを保存する実装
}
}
// テスト用実装
class InMemoryUserRepository implements UserRepository {
private users: User[] = [];
async findById(id: string): Promise<User> {
return this.users.find(u => u.id === id) || null;
}
async save(user: User): Promise<void> {
const index = this.users.findIndex(u => u.id === user.id);
if (index >= 0) {
this.users[index] = user;
} else {
this.users.push(user);
}
}
}
// どちらの実装も同じインターフェースを満たしているため、
// UserServiceは実装の詳細を気にする必要がない
class UserService {
constructor(private userRepository: UserRepository) {}
async updateUserEmail(userId: string, newEmail: string): Promise<void> {
const user = await this.userRepository.findById(userId);
user.email = newEmail;
await this.userRepository.save(user);
}
}
例2: コールバック関数のより柔軟な扱い
関数型の部分型関係を利用すると、より具体的な関数をコールバックとして渡せます:
interface BasicUser {
id: string;
name: string;
}
interface DetailedUser extends BasicUser {
email: string;
age: number;
}
// DetailedUser -> voidの関数を受け取る高階関数
function processUser(
userId: string,
callback: (user: DetailedUser) => void
): void {
// ユーザー情報を取得する処理
const user: DetailedUser = {
id: userId,
name: "John Doe",
email: "john@example.com",
age: 30
};
callback(user);
}
// BasicUser -> stringを返す関数
function displayUserName(user: BasicUser): string {
return `User name: ${user.name}`;
}
// DetailedUser -> voidの関数が必要な箇所に
// BasicUser -> stringの関数を渡せる
// (戻り値型はvoidに対して任意の型が許容され、
// 引数型はDetailedUserがBasicUserの部分型であるため)
processUser("123", displayUserName);
TypeScriptの構造的型システムと部分型関係を活用することで、静的型付けの恩恵を受けながらも、柔軟なコーディングが可能になります。