関数型における「共変性」と「反変性」の深層:TypeScriptの型システムを飼いならす
テックリードの私だ。コードレビューをしていると、未だに「なぜこのコールバックの型定義でコンパイルエラーになるのか分からない」「なんとなく `any` や `unknown` で逃げている」というコードに遭遇する。
TypeScriptの型システムは、単なる「静的チェックの道具」ではない。JavaScriptの動的な振る舞いの上に構築された、厳密な圏論(Category Theory)に基づく数学的モデルだ。その中でも、関数の引数における「反変性(Contravariance)」と戻り値の「共変性(Covariance)」は、フロントエンドの大規模設計や高度な非同期API連携において避けて通れない核心である。
今回は、この概念がコンパイル時にどのような型評価をもたらし、実務の設計にどう影響するのかを、言語の重みを知る者の視点から徹底的に解剖しよう。
—
1. そもそも「変性(Variance)」とは何か?
変性とは、「ある型が別の型にサブタイプ(派生)関係にあるとき、それらを使って構築された複雑な型(関数や配列など)の間で、サブタイプ関係がどう継承されるか」の性質を指す。
言葉だけでは抽象的すぎるため、まずはTypeScriptにおける「代入可能性(Assignability)」の基本を確認する。
type Animal = { name: string };
type Dog = { name: string; bark: () => void };
// Dog は Animal のサブタイプである(Dog は Animal の要件をすべて満たし、さらに追加機能を持つ)
let animal: Animal;
let dog: Dog = { name: `Rex`, bark: () => console.log(`Woof!`) };
animal = dog; // OK: Dog は Animal として扱える
// dog = animal; // Error: Animal には bark がないため代入不可
この「より特化した型(Dog)を、より一般的な型(Animal)の代わりに使える」という原則(リスコフの置換原則:LSP)が、型システムの土台にある。
しかし、これを「関数」というラップされた構造に適用した瞬間、話は劇的に変わる。
—
2. なぜ関数の「引数」は反変的でなければならないのか?
核心に入ろう。関数の引数の型は、「反変(Contravariant)」でなければならない。これは数学的・論理的に絶対の要請だ。
思考実験:もし引数が「共変」だったらどうなるか?
もしTypeScriptが、引数の型についても直感通り「共変(サブタイプの関係をそのまま維持する)」を許した世界線があったとしよう。つまり、`Dogを引取る関数` の代わりに `Animalを引取る関数` を渡せたとしたら、何が起きるか。
type Animal = { name: string };
type Dog = Animal & { bark: () => void };
// 「あらゆる動物を受け入れ、鳴かせる」関数
function makeAnimalBark(animal: Animal) {
// 危険:TypeScriptが共変を許した世界の架空のバグ
// 実行時エラー:animal.bark is not a function (Catが渡された場合など)
// animal.bark();
}
// 現場の要件:「犬専用のハンドラー」に、上記の汎用関数を渡したいとする
type DogHandler = (dog: Dog) => void;
// もし引数が「共変」なら代入可能になってしまう(実際にはTypeScriptはエラーにする)
// const badHandler: DogHandler = makeAnimalBark;
逆を考えよう。「犬専用のハンドラー(`Dog` を受け取って処理する関数)」が求められている場所に、「あらゆる動物を処理できる関数(`Animal` を受け取る関数)」を渡すのは安全だろうか?
答えは 完全なる「安全(Yes)」 だ。
なぜなら、「あらゆる動物(Animal)」を処理できる関数は、当然その部分集合である「犬(Dog)」が渡されても問題なく処理できるからだ。
- 求められている契約(期待):`Dog` をくれれば何とかする
- 実際に渡すもの:`Animal` なら何でも処理できる汎用ロジック
したがって、引数の型は「より広い型(スーパタイプ)」から「より狭い型(サブタイプ)」へ代入可能でなければならない。 これが反変性の正体である。
$$\text{もし } A \subtype B \text{ ならば、} (B \to C) \subtype (A \to C)$$
矢印の向きが逆転している(反変している)ことに注目してほしい。これがコンパイラ内部で評価される真実だ。
—
3. 戻り値は「共変(Covariant)」である
ついでに戻り値についても整理しておこう。関数の戻り値は共変である。
- 求められている契約(期待):何かを返したら、それは少なくとも `Animal` であってほしい
- 実際に返すもの:確実に `Dog` を返す関数
type AnimalFactory = () => Animal;
type DogFactory = () => Dog;
let getAnimal: AnimalFactory;
let getDog: DogFactory = () => ({ name: `Rex`, bark: () => {} });
getAnimal = getDog; // OK: DogFactory は AnimalFactory の代わりとして安全に使える
`Dog` は `Animal` の要件を満たしているため、「動物を返す関数」を期待されている場所に「犬を返す関数」を差し込んでも、呼び出し元は何の矛盾も感じない。こちらは素直に同じ向き(共変)になる。
—
4. 実務での応用:イベントハンドラーとコールバック設計の罠
この「引数の反変性」を理解していないと、フロントエンドのコンポーネント設計やイベント駆動アーキテクチャで手痛いバグを踏む。
以下のプロダクションコードを見てほしい。UIライブラリやカスタムフックのイベントリスナーを設計している場面だ。
// — ドメインモデルの定義 —
type UIEvent = { timestamp: number };
type MouseClickEvent = UIEvent & { x: number; y: number };
type KeyboardEvent = UIEvent & { key: string };
// イベントリスナーの型定義
type EventListener
// — 実際の設計パターン —
class EventBus {
private listeners: Map
// 任意のUIイベントを受け取るリスナーを登録する
public subscribe(eventType: string, listener: EventListener
const existing = this.listeners.get(eventType) ?? [];
this.listeners.set(eventType, […existing, listener]);
}
public publish(eventType: string, event: UIEvent): void {
const listeners = this.listeners.get(eventType) ?? [];
listeners.forEach(listener => listener(event));
}
}
ここで、開発者が「マウスイベント専用の厳密なロジック」を書こうとして、以下のようなコードを書いたとする。
const bus = new EventBus();
// マウスイベントだけに特化したハンドラーを作りたい
const handleMouseClick: EventListener
console.log(`Clicked at: ${event.x}, ${event.y}`); // MouseClickEvent のプロパティにアクセス
};
// 【コンパイルエラーになる例】
// bus.subscribe(‘click’, handleMouseClick);
//
// ❌ Argument of type ‘EventListener
// Types of parameters ‘event’ and ‘event’ are incompatible.
// Property ‘x’ is missing in type ‘UIEvent’ but required in type ‘MouseClickEvent’.
なぜこのエラーが発生するのか?(テック解説)
コンパイラはこう思考している:
1. `EventBus.subscribe` は `EventListener
2. あなたが渡そうとした `handleMouseClick` は `EventListener
3. 関数の引数は反変でなければならない。
- 要求されている引数型:`UIEvent` (広い)
- 渡された関数の引数型:モーリスの `MouseClickEvent` (狭い)
4. 「狭いものを受け取る関数」を、「広いものを渡す場所」に置くことは、実行時にプロパティ欠損(`event.x` が undefined になるなど)を引き起こすため、型安全上拒絶される。
正しい設計パターン:シグネチャのジェネリクス化
この問題をクリアし、かつ型安全性を保つには、クラスやメソッド側をジェネリックにして、反変性の制約を正しくアラインさせる必要がある。
class RobustEventBus {
// イベントの種類ごとに型を安全に推論させる設計
private listeners: Map
// マップの型をジェネクスで表現する
public subscribe
// 内部実装の型安全性を担保しつつ柔軟性を持たせる
const existing = this.listeners.get(eventType) ?? [];
this.listeners.set(eventType, […existing, listener as any]);
}
public publish
const listeners = this.listeners.get(eventType) ?? [];
listeners.forEach(listener => listener(event));
}
}
// — 使用例 —
const robustBus = new RobustEventBus();
// OK: MouseClickEvent を受け取る厳密なリスナーをそのまま登録できる
robustBus.subscribe
console.log(`X: ${event.x}, Y: ${event.y}`); // 安全に補完が効く
});
—
5. TypeScriptの隠された罠:`strictFunctionTypes`
ここでTypeScriptのコンパイラ設定についても触れておかながら、プロフェッショナルとしての知見を共有しておこう。
tsconfig.json において、`strict: true` を有効にしている場合、`strictFunctionTypes` も自動的に有効になる。
このフラグが何をしているかというと、「関数の引数を厳密に反変としてチェックする」ということだ。
実は、初期のTypeScriptでは、関数の引数は「双方向(bivariant)」に扱われていた。つまり、共変であってもエラーにならなかった。これはメソッド記法(`method(param: T): void`)において、オブジェクト指向のポリモーフィズムを書きやすくするための妥協だった。
しかし、現代の厳密なTypeScript(`strictFunctionTypes: true`)では、プロパティとしての関数代入を除き、通常の関数型は厳格に反変性が強制される。
// tsconfig.json で strictFunctionTypes: true の場合
let fn1: (arg: Animal) => void = (arg: Dog) => {
// 現代のTSでは、これはコンパイルエラーになることがある
// (もし引数が双方向に評価されない場合)
};
実務において `strict: true` を外すという選択肢は存在しない。したがって、コールバックや高階関数(Higher-Order Functions)を設計する際は、常に「この引数は本当にこの型で安全か?」を反変性のルールに照らし合わせて考える必要がある。
—
6. まとめ:アーキテクトからの提言
関数の引数における反変性は、単なるコンパイラの厳格なルールではない。「クライアントコードが期待する最小限の契約と、提供するロジックの依存関係」を正しく整えるための羅針盤である。
1. 引数は反変(Contravariant):広いものを受け取れる関数は、狭いものが求められる場所に代入できる。しかしその逆はバグの温床となる。
2. 戻り部は共変(Covariant):狭いものを返す関数は、広いものが求められる場所に安全に代入できる。
3. 設計時のマインドセット:コールバックやイベントハンドラーの型定義で詰まったら、「この関数は誰が呼び出し、何を引き渡すのか(=引数のスコープは広いか狭いか)」を逆算してジェネリクスを配置せよ。
この原理原則を脳内にインストールしたあなたなら、複雑な非同期パイプラインや型安全なカスタムフックの設計で迷うことはもうないはずだ。プロダクションコードの品質を、その手でさらに高みへと引き上げてほしい。