【入門編】TypeScriptの型推論における「共変性」と「反変性」の基礎:配列と関数の違い – TypeScript コア・型システムの基礎解析バイブル

こんにちは!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の型システムを完全に手中に収めたと言えます。

今日の学びを武器に、さらに堅牢で美しいコードを書き上げていきましょう!

タイトルとURLをコピーしました