TypeScriptの型システムを真に手懐けるには、表面的な構文の暗記ではなく、コンパイラが型を「集合(Sets)」としてどう捉え、どう評価しているかの純粋な理論的背景を理解しなければならない。
コードレビューをしていて最も絶望するのは、`Array
今回は、関数型における「共変性(Covariance)」と「反変性(Contravariance)」という、TypeScriptの型システムの根幹をなす概念を解き明かす。これが腑に落ちれば、コールバック設計や高階コンポーネント、非同期APIミドルウェアの型定義で迷うことは二度となくなるはずだ。
—
1. そもそも「部分型(Subtyping)」とは何か
共変性と反変性を語る前に、TypeScriptにおける「代入可能性(Assignability)」の正体を定義しておこう。
TypeScriptの型システムは構造的型付け(Structural Subtyping)を採用している。
ここで、`Animal` と、それを継承(拡張)した `Dog` という型を考えてみる。
type Animal = { name: string };
type Dog = { name: string; bark: () => void };
let animal: Animal = { name: ‘Generic Animal’ };
let dog: Dog = { name: ‘Pochi’, bark: () => console.log(‘Woof!’) };
// 1. Dog は Animal の「部分型(Subtype)」である
animal = dog; // OK: Dog は Animal の持つ要件をすべて満たしているため代入可能
// dog = animal; // Error: Animal には bark がないため代入不可
- スーパータイプ(Supertype / 余裕のある広範な型): `Animal`
- サブタイプ(Subtype / 特殊化された狭い型): `Dog`
「サブタイプはスーパータイプの代わりに使える(Liskovの置換原則)」という大原則をまず頭に刻んでほしい。
—
2. なぜ関数の「引数」は反変(Contravariant)になるのか?(核心)
ここからが本題だ。関数を別の関数へ「引数として渡す(コールバック)」場面を想像してほしい。
ここに、`Dog` を受け取って何かをするハンドラー関数を要求するシステムがあるとする。
type DogHandler = (dog: Dog) => void;
この `DogHandler` を要求する関数に、「どんな `Animal` でも扱える関数(`AnimalHandler`)」を渡そうとしたとき、型安全性はどうなるだろうか?
const handleAnimal: (animal: Animal) => void = (animal) => {
console.log(animal.name);
};
// DogHandler が期待されている場所に、AnimalHandler を渡せるか?
function processDog(handler: DogHandler) {
const myDog: Dog = { name: ‘Hachi’, bark: () => console.log(‘Wan!’) };
handler(myDog); // 実行時、DogHandler は Dog を渡してくる
}
processDog(handleAnimal); // TypeScriptはこれを「OK」と判定する
なぜこれが安全なのか?
`handleAnimal` は 「どんな Animal が来ても処理できる」 という契約を結んでいる。
したがって、より限定された情報しか持たない(しかし `Animal` の要件は満たしている)`Dog` が渡されてきても、問題なく処理を完遂できる。
つまり、
- 要求されている型(引数): `Dog` (狭い・サブタイプ)
- 実際に渡す関数が受け取れる型(引数): `Animal` (広い・スーパータイプ)
という「型の階層が逆転する現象」が起きる。これが反変性(Contravariance)だ。
逆に、もし「`Dog` しか扱えない関数」を、`Dog` を期待する場所に渡そうとしたらどうなるか?
const handleDogOnly: (dog: Dog) => void = (dog) => dog.bark();
function processGenericAnimal(handler: (animal: Animal) => void) {
const genericAnimal: Animal = { name: ‘Unknown Beast’ };
handler(genericAnimal); // 実行時、Animal が渡される!
}
// processGenericAnimal(handleDogOnly);
// ❌ もしこれが通ると、bark のない Animal が渡されて runtime error (TypeError: dog.bark is not a function) が起きる。
だからこそ、TypeScriptコンパイラは「関数の引数の位置においては、サブタイプの代わりにスーパータイプを渡すことはできないが、逆(スーパータイプの代わりにサブタイプ…ではなく、さらに広い型)は許可する」という厳格なルール、すなわち反変性を強制しているのだ。
—
3. では「戻り値」はどうなるのか?(共変性)
一方、関数の戻り値(Return Type)はどうだろうか。
type AnimalFactory = () => Animal;
// 戻り値として Dog を返す関数
const createDog: () => Dog = () => ({ name: ‘Pochi’, bark: () => {} });
let factory: AnimalFactory = createDog; // OK!
- 期待されている戻り値: `Animal` (広い)
- 実際に返す関数の戻り値: `Dog` (狭い)
これは直感的だ。`Animal` をくれと言われている場所で、より具体的でリッチな情報を持つ `Dog` を返してあげる分には、受け取り手にとって何ら不都合はない。
このように、「戻り値の型は、代入元の型階層の方向と一致する」。これを共変性(Covariance)と呼ぶ。
> まとめ:関数型のサブタイピング規則
> `(param: T1) => R1` は `(param: T2) => R2` のサブタイプであるための条件:
> 1. 引数(Param): `T2` は `T1` のサブタイプである必要がある(反変:方向が逆)
> 2. 戻り値(Return): `R1` は `R2` のサブタイプである必要がある(共変:方向が同じ)
—
4. 実務での応用:堅牢なイベントリスナー / APIミドルウェアの設計
この理論が、フロントエンドの現場でどう活きるのか。実際のプロダクションコードを想定した設計パターンを見てみよう。
例えば、UIコンポーネントから発火するイベントをハンドリングする、高度なイベントバス(Event Bus)を設計するとする。
❌ ありがちな脆弱なコード(`any` の乱用)
type EventPayload = { id: string; timestamp: number };
type ClickEvent = EventPayload & { x: number; y: number };
// とりあえず any にして型エラーを回避するアンチパターン
type BadListener = (event: any) => void;
function registerListener(listener: BadListener) {
// …
}
これでは型安全性の恩恵を一切受けられず、Runtimeでプロパティ欠損によるバグを生む温床になる。
⭕ プロダクションクオリティの設計(変性を意識したコールバック設計)
ここで、特定のサブイベントを処理するハンドラーを安全に登録できる、共変・反変を正しく調停した設計を実装する。
// — ドメインモデルの定義 —
interface BaseEvent {
readonly timestamp: number;
}
interface UserClickEvent extends BaseEvent {
readonly type: ‘CLICK’;
readonly x: number;
readonly y: number;
}
interface UserInputEvent extends BaseEvent {
readonly type: ‘INPUT’;
readonly value: string;
}
type AppEvent = UserClickEvent | UserInputEvent;
// — イベントディスパッチャの堅牢な型定義 —
/
- EventHandler は AppEvent のサブタイプを引数に取る関数を受け入れる。
- ここで反変性が正しく作用し、特定のイベントに特化したハンドラーを安全に登録できる。
/
class EventBus
private listeners: ((event: TEvent) => void)[] = [];
// 購読側:渡すコールバックの引数は、TEvent の「スーパータイプ」であれば何でも受け入れ可能(反変)
public subscribe
// TypeScript の strictFunctionTypes により、
// 引数の型が厳密にチェックされる
this.listeners.push(listener as (event: TEvent) => void);
}
public dispatch(event: TEvent): void {
this.listeners.forEach((listener) => listener(event));
}
}
// — 実戦での利用例 —
const globalBus = new EventBus
// 1. すべての AppEvent を処理できる汎用ロガー(引数が広い = スーパータイプに近い)
const auditLogger = (event: BaseEvent) => {
console.log(`[Audit] Event at ${event.timestamp}`);
};
// globalBus.subscribe(auditLogger); // OK! BaseEvent は AppEvent のスーパータイプ
// 2. ClickEvent 専用のハンドラー(引数が狭い = サブタイプ)
const handleClick = (event: UserClickEvent) => {
console.log(`Clicked at X:${event.x}, Y:${event.y}`);
};
// 💡 ここがポイント:
// AppEvent を要求する bus.subscribe に対して、
// より限定された UserClickEvent を引数に取る handleClick を渡している。
// 「引数の反変性」のルールに基づき、コンパイラはこれを安全と判定して通す!
globalBus.subscribe(handleClick);
もしここで `strictFunctionTypes: false`(非推奨)に設定していたり、型を雑に定義していると、存在しないプロパティにアクセスするバグが型コンパイルをすり抜けて本番環境に到達してしまう。
—
5. テクニカルリードからの最終助言
TypeScriptのコンパイラオプションにある `strictFunctionTypes` は、デフォルトで `true`(`strict: true` に含まれる)になっている。これは、関数の引数が完全に反変としてチェックされることを意味する。
古いTypeScriptや、JSからの移行期にはこのチェックを緩くするコードが見られたが、モダンなフロントエンド開発においてこれを無効化する理由は一切存在しない。
- 引数は反変(Contravariant):広い受容力を持つ関数は、狭い要求を必要とする場所に代入できる。
- 戻り値はコバリアント(Covariant):狭い確実な戻り値を返す関数は、広い戻り値を期待する場所に代入できる。
この原則を頭の片隅ではなく、脊髄反射レベルで理解していれば、複雑なジェネリクスや関数型フック(Reactの `useCallback` のシグネチャ設計など)に直面したときも、コンパイラの挙動が完全に見通せるようになるはずだ。型に頼るな、型を支配しろ。