TypeScriptの関数型はなぜ「引数だけ」裏切るのか?:共変性と反変性を掌握する
TypeScriptを使いこなしているつもりでも、ふとした瞬間に型エラーに遭遇し、「なぜこの代入が許されないのか?」と頭を抱えたことはないだろうか。
特に、関数を引数として渡す際、期待した型が弾かれる現象。これこそが、TypeScriptの型システムにおける最大の難所であり、かつ最も強力な武器である「反変性(Contravariance)」の正体だ。
この記事では、表面的な構文の解説は飛ばす。コンパイラが裏で何を考え、なぜそのようなルールを課しているのか。その核心に迫る。
—
1. 直感の罠:共変性と反変性
まず、用語を整理しよう。
- 共変性 (Covariance): 「より具体的」な型を「より抽象的」な型へ代入できる性質。例:`string[]` は `(string | number)[]` に代入できる。
- 反変性 (Contravariance): 「より抽象的」な型を「より具体的」な型へ代入できる性質。これが関数の引数で発生する。
なぜ引数は「反変」なのか?
直感に反して、TypeScriptの関数引数は反変である。つまり、引数の型は「より緩い(抽象的な)」ほうが、より厳格な関数に代入できる。
type Animal = { name: string };
type Dog = { name: string; bark: () => void };
// 「犬」を扱う処理
type DogHandler = (d: Dog) => void;
// 「動物」を扱う処理
const handleAnimal: (a: Animal) => void = (a) => {
console.log(a.name);
};
// 疑問:handleAnimal を DogHandler として使っても安全か?
const handler: DogHandler = handleAnimal; // 正常!
なぜこれが許されるのか。それは、`DogHandler` を期待するコードは「`bark`メソッドが存在すること」を前提に動くからだ。もし引数が `Animal` 型であれば、その関数は `name` プロパティさえあれば安全に処理を完結できる。つまり、受け手側は「どんな犬が来ても大丈夫」な状態を担保できているため、型安全性が維持されるのだ。
逆に、引数が「より具体的」な型を要求している関数に、抽象的な関数を渡すと実行時エラーになる。これがTypeScriptがコンパイル時に防いでいる「隠れた地雷」である。
—
2. 実務で遭遇する「罠」:コールバック関数の型定義
実務で最も痛い目を見るのが、ライブラリのコールバック型定義だ。
interface EventPayload {
id: string;
timestamp: number;
}
interface ClickEvent extends EventPayload {
x: number;
y: number;
}
// 悪い設計例:厳密すぎる型定義
type ClickHandler = (event: ClickEvent) => void;
function processEvent(handler: ClickHandler) {
const event: ClickEvent = { id: ‘1’, timestamp: 0, x: 10, y: 10 };
handler(event);
}
// 汎用的なハンドラを渡そうとすると…
const logEvent = (e: EventPayload) => console.log(e.id);
// エラー発生!
// Argument of type ‘(e: EventPayload) => void’ is not assignable to parameter of type ‘ClickHandler’.
processEvent(logEvent);
なぜエラーになるのか?
`processEvent` は「`x`, `y` 座標が必ずあること」を期待しているが、`logEvent` は「`id` があればいい」としか約束していない。もし `logEvent` 内で `e.x` にアクセスしようとしたら、実行時に `undefined` で爆発する。コンパイラは、その「可能性」を事前に摘み取っているのだ。
解決策:共変性を活かしたインターフェース設計
これを回避するには、コンポーネントやAPIの引数を定義する際、「その関数が本当に必要としている最小限のプロパティ」を型として定義することだ。
// 推奨:必要なプロパティだけを絞り込む
type PartialEvent = Pick
type RobustHandler = (e: PartialEvent) => void;
const logEvent: RobustHandler = (e) => console.log(e.id);
processEvent(logEvent); // 安全に通過
—
3. チーフアーキテクトからの提言:堅牢な設計のために
TypeScriptの型システムは「静的な正しさ」を保証するものであり、実行時の柔軟性を制限するものではない。以下の原則を守るだけで、あなたの書くコードは劇的に堅牢になる。
1. 引数は「寛容」に、戻り値は「厳格」に:
引数(反変)は受け取れる型の範囲を広く(抽象的に)し、戻り値(共変)は呼び出し元が扱いやすい具体的な型を返すように設計せよ。
2. `unknown` を活用せよ:
コールバックの引数が何であるか確信が持てない場合、`any` に逃げず `unknown` を使う。`unknown` は最も抽象的な型であり、反変性のルール上、最も代入可能性が高い。
3. 複雑な型は `infer` で解決する:
もしライブラリが提供する関数の型が複雑すぎて手に負えない場合は、`Parameters
最後に
反変性は単なる数学的なパズルではない。「利用側が何を要求し、提供側が何を保証できるか」という契約(Contract)の厳密な定義である。
この概念を理解していれば、`TS2322` エラーが出た瞬間に「ああ、要求レベルと提供レベルの抽象度の不一致だな」と即座に判断できるはずだ。型システムと対話し、コンパイラをあなたの味方につけろ。それが、伝説的なアーキテクトへの第一歩だ。