こんにちは!TypeScriptを触っていると、「あれ、この代入エラー、なんで起きるんだろう?」と首を傾げたくなる瞬間がありませんか?特に、関数を別の変数に代入したり、コールバックとして渡したりするときに、型がピタリと合わなくてモヤモヤした経験、きっとありますよね。
今回は、そんなTypeScriptの型システムの奥底で静かにうごめく「共変性(Covariance)」と、その最大の難所でありながら超重要な「反変性(Contravariance)」について、一緒に紐解いていきましょう!
ここをクリアできれば、あなたのTypeScriptの型に対する解像度は劇的に上がります。「なるほど、コンパイラは頭の中でこう考えていたのか!」と腑に落ちる瞬間を用意していますので、ぜひ最後までリラックスしてついてきてくださいね。
—
1. そもそも「共変性」と「反変性」ってなに?(怖くない基本のイメージ)
難しそうな名前がついていますが、要するに「型の親子関係(サブタイプ関係)が、別の型に包まれたときにどう振る舞うか」というルールのお話です。
言葉だけだとフリーズしそうになるので、身近な例でイメージしてみましょう。
- 「動物 (Animal)」 という大きなくくり(親)がいます。
- 「犬 (Dog)」 は、動物の一種ですね(子)。
「犬」は「動物」の機能を持っているので、「動物を要求される場所には、犬を差し出しても問題なく動く」――これが型の世界における基本的な「代入可能性(Liskovの置換原則)」です。
これを踏まえて、戻り値と引数がそれぞれどう振る舞うのかを見ていきましょう!
—
2. 戻り値は「共変(Covariant)」である
まずは、関数の戻り値から。ここは直感的で、初学者の方でもスッと理解しやすいところです。
共変性とは何か
戻り値の型における共変性とは、「関数の出力(戻り値)は、より具体的な型(子)を返しても、より抽象的な型(親)を期待する場所でそのまま使える」という性質です。
// 親:動物
type Animal = { name: string };
// 子:犬(動物を継承・包含している)
type Dog = { name: string; bark: () => void };
// 動物を返す関数を期待されているとする
let fetchAnimal: () => Animal;
// 犬を返す関数
let fetchDog: () => Dog = () => ({
name: “ポチ”,
bark: () => console.log(“ワン!”),
});
// ★ これはOK!
// 「動物をくれるはずが、もっと具体的な『犬』をくれたぞ。
// 犬は最低限『動物』としての性質(name)を持ってるから、そのまま扱えるね!」
fetchAnimal = fetchDog;
【図解的イメージ】
期待値(親): ───[ 動物 (Animal) ]───
▲
│ 代入OK!
│
実測値(子): ───[ 犬 (Dog) ]───
矢印が同じ方向(子から親へ代入できる)を向いているので、これを「共変(Covariant)」と呼びます。戻り値に関しては、私たちの直感の通りに動いてくれるので安心ですね。
—
3. 引数は「反変(Contravariant)」でなければならない(ここが本丸!)
さて、本日のメインテーマです。関数の引数はどうでしょうか?
「引数も同じように、親から子へ代入できるはず……」と思いきや、TypeScript(および厳密な型システムを持つ言語全般)では、真逆の現象が起きます。
なぜ引数は反変でなければならないのか?
結論から言うと、「関数の引数の型関係は、代入する側とされる側で逆転(反転)する」必要があります。これを「反変(Contravariance)」と呼びます。
百聞は一見にしかず。まずは「もし引数が共変(直感的)だったらどうなるか」の危険な世界をコードで覗いてみましょう。
// 動物を受け取って何かする関数を「期待」している場所
let handleAnimal: (animal: Animal) => void;
// 「犬専用」の処理をする関数
let handleDog: (dog: Dog) => void = (dog) => {
dog.bark(); // 犬特有のメソッドを呼んでいる!
};
// ————————————————–
// もし、これがもしコンパイルエラーにならずに通ってしまったら……?
// ————————————————–
// handleAnimal = handleDog; // (※TypeScriptの通常設定ではエラーになります)
// 想像上の危険な実行シナリオ:
// 1. 呼び出し側は「どんな動物(Catなど)でも渡していいよ」と思ってこう呼ぶ
// handleAnimal({ name: “タマ(猫)” });
// 2. しかし、中身は handleDog なので、引数に「タマ(猫)」が突っ込まれる
// 3. handleDog は「これは犬だ!」と信じ込んでいるので、dog.bark() を実行する!
// 4. 猫には bark がないので、実行時エラー(TypeError: dog.bark is not a function)で爆死💥
この惨劇を防ぐため、型システムは次のようにルールを定めています。
> 「より広い範囲を受け取れる関数(親の引数)は、より狭い範囲を期待する場所(子の引数)に代入しても安全である」
これを逆から見てみましょう。「代入される側の引数(親)」に対し、「代入する側の引数(子)」ではなく、「さらに何でも受け入れられる親の関数」でなければ代入できないのです。
// 犬を受け取る関数を「期待」されている場所
let handleDog: (dog: Dog) => void;
// 「どんな動物でも」受け取れる懐の深い関数
let handleAnimal: (animal: Animal) => void = (animal) => {
console.log(animal.name); // 動物のプロパティ(name)しか触らない!安全!
};
// ★ これはTypeScriptでもOK!
handleDog = handleAnimal;
【図解的イメージ】
期待値(子): ───[ 犬 (Dog) ]─── (犬を扱いたい)
│
│ 代入OK! (矢印が逆転=反変)
▼
実測値(親): ───[ 動物 (Animal) ]─── (どんな動物でもウェルカム!)
引数の世界では、「受け入れる間口が広い(親である)関数ほど、ピンポイントな場所(子)に代入できる」という、一見すると逆転した現象が起きます。これが反変性(Contravariance)の正体です。
—
4. 現場で直面する「strictFunctionTypes」の罠
「なんだか理屈は分かったけれど、実際の開発ではどう影響するの?」と思いますよね。
実は、TypeScriptのtsconfig.jsonには、この反変性を厳密にチェックするためのフラグが存在します。それが `strictFunctionTypes` です(`strict: true` を有効にしていると、自動的に有効になります)。
陥りやすい文法エラーの例
コールバック関数を持つオブジェクトやメソッドを定義するときに、この反変性の罠にハマりがちです。
interface EventBus {
// イベントリスナーの登録関数
on(listener: (event: Dog) => void): void;
}
// 実際に用意したイベントハンドラー(「動物全般」を受け取るつもり)
const handleAnyAnimal = (event: Animal) => {
console.log(event.name);
};
const bus: EventBus = {
on(listener) {
// …
}
};
// ここでエラーになることがあります!
// 「listenerの引数は Dog を期待しているのに、
// handleAnyAnimal は Animal を受け取る(反変の関係が逆、またはメソッド記法のBivariance都合)」
> 💡 知っておくべき実用知識(メソッドのBivariance)
> TypeScriptでは、オブジェクトの「メソッド構文(`on(listener: …)`)」と「プロパティに代入する関数構文(`on: (listener: …) => void`)」で、引数の型の扱いが異なります。
> メソッド構文は利便性のため双変(Bivariant:共変も反変も両方OKとする緩いルール)として扱われますが、アロー関数などで厳密に型定義を行うと、今回の反変性がガッツリ適用されます。ライブラリの型定義などでハマったときは、この関数の構造を思い出してくださいね。
—
まとめ:ここをクリアすればTypeScriptは怖くない!
お疲れ様でした!今回は少し数学的でディープな「関数の共変性と反変性」について解説しました。
- 戻り値は「共変(Covariant)」:
- 子を返す関数は、親を期待する場所に代入できる(OK)
- 引数は「反変(Contravariant)」:
- 親を受け入れる(間口の広い)関数は、子を期待する場所に代入できる(OK)
最初は「なぜ引数だけ逆になるんだ!?」と混乱するかもしれませんが、「呼び出し側が渡すデータ」と「関数側が受け取る心構え」の安全性をコンパイラが厳密に担保しているからこそ、このルールが存在します。
ここを理解できれば、複雑なコールバックやジェネリクス、高度な型パズルに直面しても、「あ、これは反変のルールで弾かれているんだな」と冷静にコードを読み解けるようになりますよ。
日々の型定義ライフが、より快適でクリアなものになりますように。それでは、また次の記事でお会いしましょう!