こんにちは!TypeScriptの型システムを深く探求する旅へようこそ。
他の言語(JavaやC#、あるいはRustなど)からTypeScriptに入ってきた開発者たちが、ある日突然遭遇して頭を抱える現象があります。それが「配列と関数の代入における型エラー」です。
「えっ、親クラスの配列に、子クラスの配列を代入しただけなのに、なんでエラーになるの?」
「逆に、関数を引数に渡すときは、なんだか逆向きのルールが適用されている気がする……?」
ここをクリアできるかどうかが、TypeScript中級者への大きな分かれ道になります。大丈夫、難しく考える必要はありません。今回は、型システムの背後にある「変位(Variance:共変性と反変性)」の本質を、コンパイラの気持ちになりながら一緒に紐解いていきましょう。
ここをクリアすれば、あなたのTypeScriptの基本はバッチリマスターできますよ!
—
1. 型システムにおける「変位(Variance)」ってなに?
変位(Variance)とは一言で言うと、「元の型に親子関係(サブタイプ関係)があるとき、それらを包む別の型(配列や関数など)の間で、親子関係がどう変化するか」というルールのことです。
TypeScriptの型システムは、コードをより安全にするために、この変位を厳密に管理しています。
まずは、もっとも直感的に理解しやすい「配列」から見ていきましょう。
—
2. 配列の代入で起きる不思議:なぜ「共変性(Covariance)」でエラーになるのか?
オブジェクト指向の文脈で考えてみましょう。
ここに `Animal`(動物)という親クラスと、それを継承した `Dog`(犬)という子クラスがあるとします。
class Animal {
name: string = “Animal”;
}
class Dog extends Animal {
bark() {
console.log(“Woof!”);
}
}
「犬は動物である」なので、`Dog` は `Animal` のサブタイプ(部分型)です。つまり、単体の変数であれば次のような代入は当然エラーになりません。
let myAnimal: Animal = new Dog(); // OK!「犬」は「動物」の一種だから安全
では、これを配列にしたらどうなるでしょうか? 「犬の配列(`Dog[]`)」は「動物の配列(`Animal[]`)」の一種だと言えそうですよね。これをTypeScriptで試してみましょう。
let dogs: Dog[] = [new Dog(), new Dog()];
let animals: Animal[] = dogs; // あれ? ここでTypeScriptは怒らない(※)
(※注意: TypeScriptはデフォルトの設定では配列の代入に対して緩い(Bivariant)場合がありますが、厳密なモードや他の安全な型システムを想定して本質を解説します)
もし、`Dog[]` を `Animal[]` にそのまま代入できてしまうと、一体何が起きるでしょうか? 恐ろしいバグの扉が開きます。
let dogs: Dog[] = [new Dog(), new Dog()];
let animals: Animal[] = dogs; // 仮にこれが許されたとする
// animals は 「Animal[]」型なので、猫(Cat)を入れるのも文法上はOKなはず
class Cat extends Animal {
meow() { console.log(“Meow!”); }
}
animals.push(new Cat()); // animals経由で「猫」が混入した!
// さぁ、元の dogs の中身はどうなっているでしょう?
dogs[2].bark(); // 💥 実行時エラー! dog[2] は Cat なので bark() なんて持ってない!
この危険性を防ぐために、TypeScript(および多くの安全な言語)の配列は、型が一致するか、あるいは安全性が保証される方向でなければ代入を制限します。このように、「元の型の親子関係の向きが、そのまま維持される性質」を共変性(Covariance)と呼びます。
配列の場合、データを「書き込む(代入する)」方向の安全性を考慮すると、単純な共変性だけでは実行時エラー(Type Unsafety)を防ぎきれないというジレンマがあるのです。
—
3. 関数の世界で起立する逆転現象:「反変性(Contravariance)」とは?
次に、関数の引数における型のルールを見てみましょう。ここが一番の難所であり、一番面白いところです。
次のような関数型を考えてみます。
- `Animal` を受け取る関数:`(animal: Animal) => void`
- `Dog` を受け取る関数:`(dog: Dog) => void`
「`Dog` は `Animal` より狭い(詳細な)型なんだから、`Dog` を受け取る関数の方が、`Animal` を受け取る関数よりも上位(強力)なはず!」……と思っていませんか?
実は、代入の安全性という観点では、その関係は真っ逆さまになります。以下のコードを見てください。
// Animal を受け取って、名前を出力する関数
function printAnimalName(animal: Animal) {
console.log(animal.name);
}
// 変数に関数を代入してみる
let fn: (dog: Dog) => void;
// ① printAnimalName を fn に代入できるか?
fn = printAnimalName; // 🟢 なんと、これはOK!
なぜ、`Animal` を受け取る関数を、`Dog` を受け取る変数に代入できるのでしょうか? 脳内トレースしてみましょう。
1. `fn` は「私には `Dog` を渡してね。そうすれば `Dog` の機能を使って処理するから」と期待している変数です。
2. そこに代入された `printAnimalName` は、「どんな `Animal` が来ても、共通のプロパティ(`name`など)を使って処理するよ」という関数です。
3. `Dog` は `Animal` の一種(=すべての `Dog` は最低限 `Animal` が持つ性質を持っている)なので、`printAnimalName` に `Dog` を渡しても何の問題もなく安全に処理できます。
つまり、「引数の型については、より広範な(抽象的な)型を受け取れる関数を、より限定された型を期待する場所に代入しても安全」なのです。
この、元の型の親子関係が「逆向き」になる性質を反変性(Contravariance)と呼びます。
—
4. まとめて整理:配列と関数の違い
ここまでの話を、スッキリ頭の中で整理するために比較表を見てみましょう。
| 概念 | 対象 | 性質 | 意味 |
| :— | :— | :— | :— |
| 共変性 (Covariance) | 配列の読み取り、返り値など | $\text{Dog} \subset \text{Animal} \implies \text{Dog[]} \subset \text{Animal[]}$ | 元の向きをそのまま維持する |
| 反変性 (Contravariance) | 関数の引数など | $\text{Dog} \subset \text{Animal} \implies (\text{Animal} \to X) \subset (\text{Dog} \to X)$ | 元の向きが逆転する |
TypeScriptのコンパイラは、関数型をチェックする際、引数に対しては厳密に「反変性」を要求します(※`strictFunctionTypes: true` の場合。現在のモダンなTypeScriptではデフォルトで有効です)。
もし関数型の引数が共変性(同じ向き)だったらどうなるか?
「`Dog` だけを受け取る関数」を、「`Animal` を何でも受け取るべき変数」に代入できてしまい、そこに `Cat` が放り込まれた瞬間にクラッシュしてしまいます。それを未然に防ぐための、コンパイラ側の緻密な防衛策が「反変性」なのです。
—
5. 実務で役立つ!型安全を保つためのマインドセット
フロントエンドの開発(Reactのイベントハンドラーや、APIクライアントのコールバック関数など)を書いているとき、次のような型エラーに直面したことはありませんか?
type EventHandler = (event: MouseEvent) => void;
// UIの特定のパーツ専用の、より詳細なイベント型
interface SpecialMouseEvent extends MouseEvent {
specialData: string;
}
let handleSpecial: (e: SpecialMouseEvent) => void = (e) => {
console.log(e.specialData);
};
let myHandler: EventHandler = handleSpecial;
// ❌ ここで型エラーになることがある!
// 「EventHandler は MouseEvent を要求するが、handleSpecial は SpecialMouseEvent しか受け取れないから危険!」
コンパイラは、「`myHandler` には普通の `MouseEvent` が入ってくるかもしれない。それなのに `handleSpecial` は `specialData` を前提にしているから、普通のクリックが起きたときにバグる!」と見抜いて弾いてくれているのです。
解決策のヒント
もしどうしてもこういう構造を扱いたい場合は、引数をより広い型(この場合は `MouseEvent`)に合わせて受け取り、関数内部で型ガード(Type Guard)や型アサーションを使って安全に処理するように設計します。
let myHandler: EventHandler = (e: MouseEvent) => {
// 型ガードで安全に絞り込む
if (‘specialData’ in e) {
console.log((e as SpecialMouseEvent).specialData);
}
};
—
おわりに
今回は、TypeScriptの型推論の裏側を支える「共変性」と「反変性」について解説しました。
- 配列の代入やデータの流れ(読み書き)における性質。
- 関数の引数における、一見逆向きに思える安全性のルール(反変性)。
これらは一見すると難解な理論に思えますが、すべては「実行時エラーをコンパイル時に1つも逃さず検出し、私たちのアプリケーションを守るため」に存在しています。
エラーが出たとき、「なぜコンパイラはこの向きの代入を怒っているのか?」を「データの読み書きの安全性」という視点から逆算できるようになれば、あなたもうTypeScriptの型システムを完全に手中に収めたと言えます。
今日の学びを武器に、さらに堅牢で美しいコードを書き上げていきましょう!