こんにちは!フロントエンドからバックエンドまで、TypeScriptの荒波を日々一緒に乗り越えている先輩エンジニアです。
今回は、TypeScriptの型システムの中でも、多くの人が「あれ?」とつまずき、そしてここを理解した瞬間に「TypeScriptの型を完全に掌握した!」と視界が開ける特別なトピックをお届けします。
テーマは、関数型における「共変性(Covariance)」と「反変性(Contravariance)」です。
なんだか声に出して読みたくなるような、いかにも難しそうな名前をしていますよね。でも大丈夫。一歩ずつ、図解的なイメージと具体的なコードを交えながら、優しく、そして本質的なところまで紐解いていきますね。
ここをクリアすれば、関数を取り扱うあなたのTypeScriptスキルは一段も二段も跳ね上がります。一緒にバッチリマスターしていきましょう!
—
1. そもそも「関数の代入可能性」ってなんだろう?
TypeScriptを書いていると、ある関数を別の変数に代入したり、関数の引数として別の関数を渡したりすることが日常茶飯事ですよね。
例えば、こんなシンプルな状況を想像してみてください。
type Greeter = (name: string) => void;
let sayHello: Greeter = (name: string) => {
console.log(`こんにちは、${name}さん!`);
};
ここで疑問が湧きませんか? 「どんな関数なら、別の関数の代わりに代入していいんだろう?」 という疑問です。
実はTypeScriptのコンパイラは、関数の「引数」と「戻り値」の型を見て、「この代入は安全か?」を厳密にジャッジしています。このジャッジのルールを理解することが、今回のゴールへの第一歩です。
—
2. まずは「戻り値の共変性(Covariance)」から直感的に理解する
まずは怖くない方、戻り値の話から始めましょう。
ここに「動物(Animal)」と、それを継承した「犬(Dog)」という親子関係の型があるとします。
interface Animal {
name: string;
}
interface Dog extends Animal {
bark(): void;
}
「犬は動物である(Dog is an Animal)」というのは、直感的にすんなり入ってきますよね。
では、次に「動物を返す関数」と「犬を返す関数」を考えてみます。
let getAnimal: () => Animal;
let getDog: () => Dog = () => ({ name: “ポチ”, bark: () => console.log(“ワン!”) });
// ── さて、どっちの代入がセーフで、どっちがアウトでしょうか? ──
// パターンA: 動物を返す変数に、犬を返す関数を入れる
getAnimal = getDog; // 🟢 セーフ!
// パターンB: 犬を返す変数に、動物を返す関数を入れる
getDog = getAnimal; // ❌ アウト!(コンパイルエラー)
なぜパターンA(動物を返す変数に、犬を返す関数を入れる)がセーフなのでしょうか?
頭の中でこうイメージしてください。
- 「動物をください」と頼まれたとき、確実に「犬」が返ってくれば、もらった側としては「犬は動物の要件を満たしている(むしろパワーアップしている)」ので、大満足で受け取れますよね。
- 逆に、パターンBは「犬をください」と頼まれているのに、普通の「動物(もしかしたらネコかもしれない)」が返ってくるかもしれないので、受け取った側が `dog.bark()` と叫んだ瞬間にエラーになってしまいます。
このように、「より具体的な型(子)を返す関数は、より抽象的な型(親)を期待する場所へ代入できる」という性質を、共変性(Covariance)と呼びます。戻り値は、矢印の向き(型階層の向き)と同じ方向に安全性が保たれるので、覚えやすいですね。
—
3. 本丸登場:「引数の反変性(Contravariance)」の罠
お待たせしました。ここからが今回のハイライトであり、多くの開発者が「えっ、なんで!?」と頭を抱えるポイント、引数の反変性です。
先ほどと同じように、「動物」と「犬」の型を使って、今度は引数を受け取る関数を考えてみましょう。
type AnimalProcessor = (animal: Animal) => void;
type DogProcessor = (dog: Dog) => void;
let processAnimal: AnimalProcessor = (animal) => {
console.log(`処理中: ${animal.name}`);
};
let processDog: DogProcessor = (dog) => {
dog.bark();
};
// ── さて、今度はどっちの代入が許されるでしょうか? ──
// パターンC: 犬の処理をする変数に、動物の処理をする関数を入れる
let handleDog: DogProcessor = processAnimal; // 🟢 あれっ、セーフ!?
// パターンD: 動物の処理をする変数に、犬の処理をする関数を入れる
let handleAnimal: AnimalProcessor = processDog; // ❌ アウト!(コンパイルエラー)
……どうでしょう? パターンCがコンパイルを通るのを見て、「えっ、犬専用の処理を求められているのに、ざっくり動物全般を扱う関数を突っ込んでいいの?」と混乱しませんでしたか?
ここに、TypeScript(および厳密な型理論)の美しくもシビアな現実があります。実行時の安全性を完璧に担保するためのロジックを紐解いてみましょう。
なぜ「動物を処理できる関数」を「犬の処理をする変数」に代入できるのか?(パターンCがセーフな理由)
`handleDog` という変数は、世間に対してこう宣言しています。
> 「私に渡してくれれば、どんな Dog(犬)でも完璧に処理してあげるよ!」
そこに、`processAnimal`(動物なら何でも処理できるベテランの獣医さん)を代入しました。
- 実際に `handleDog` が呼び出されるとき、外から渡されてくるのは間違いなく Dog(犬) です。
- ベテラン獣医さん(`processAnimal`)は、「動物全般」を診ることができます。当然、犬も動物の一部ですから、渡された犬を何の問題もなく診察(処理)できますよね。
つまり、「要求されている範囲(犬)よりも、広く受け入れられる能力(動物全体)」を持っていれば、代入しても絶対に破綻しないのです。これが、引数の型における反変性(Contravariance)です。矢印の向きが「逆(反する)」になるから「反変」と呼ばれます。
なぜ逆はダメなのか?(パターンDがアウトな理由)
逆に、`handleAnimal`(動物なら何でも処理する変数)に、`processDog`(犬専用の処理をする関数=新人のお医者さんで犬しか診察できない)を代入しようとした場合を考えてみます。
- 外から「猫」が流れてきたとします。
- `handleAnimal` なので猫が来るのは完全に想定内です。
- しかし、代入されている中身は「犬しか診察できない新人のお医者さん(`processDog`)」です。
- 新人医師に猫を渡した瞬間、「僕、犬しか診察できません!」と言ってパニック(実行時エラー)になってしまいますよね。
だからこそ、TypeScriptのコンパイラはパターンDをビシッとエラーではじいてくれるのです。
—
4. 現場で役立つ!strictFunctionTypes のお話
ここで、実務でTypeScriptを書くときに知っておくべき重要な裏話をひとつ。
実は、TypeScriptの黎明期や、一部の古い設定(または設定が不十分な場合)、関数の引数は「共変」として緩く扱われていました(上記のパターンDのような危険な代入もエラーにならない仕様でした)。
しかし、現代のTypeScriptでは、`tsconfig.json` の中で `strict: true`(または個別の `strictFunctionTypes: true`)を有効にすることがデファクトスタンダードになっています。
{
“compilerOptions”: {
“strict”: true
}
}
このフラグがオンになっているおかげで、TypeScriptは引数の「反変性」を厳密にチェックし、私たちが書いたコードの安全性をコンパイル時に担保してくれているのです。もしこのチェックがなかったら、現場で「動かしてみるまで分からない謎の型エラー」に泣かされることになっていたでしょう。コンパイラに感謝ですね!
—
まとめ:ここをクリアすればTypeScriptは怖くない!
お疲れ様でした! 今回のポイントをギュッと凝縮して振り返ってみましょう。
1. 戻り値は「共変(Covariance)」
- より具体的な型(子)を返す関数は、より抽象的な型(親)を期待する場所に代入できる。(もらう側は大満足)
2. 引数は「反変(Contravariance)」
- より広い範囲を受け入れられる(親の型を引数に持つ)関数は、より限定された範囲(子の型を要求する場所)に代入できる。(渡す側も受け取る側も安全)
この「共変」と「反変」の概念は、一見すると頭がこんがらがりますが、「誰が何を要求し、何が渡されるのか」を現実のシチュエーションに置き換えてイメージすると、綺麗に腑に落ちるようになります。
ここを自分の言葉で説明できるようになれば、ライブラリの型定義を読んだり、複雑なジェネリクスを設計したりするときも、もう迷うことはありません。
あなたのTypeScriptの旅が、より楽しく、より確かなものになりますように。それではまた次回の記事でお会いしましょう!