定義
変性 (variance) とは、型どうしの上下関係が何かで包んだあとも保たれるかを決めるルールです。
Dog が Animal の一種だとしましょう。では Dog[] も Animal[] の一種でしょうか。Dog を受け取る関数は、Animal を受け取る関数の一種でしょうか。答えは位置ごとに違っていて、その答えを決めてくれるのが変性です。
4 つのケースがあります。
- 共変 (covariant) — 関係がそのまま保たれます。
Dog[]をAnimal[]の位置に入れられます。 - 反変 (contravariant) — 関係がひっくり返ります。
Animalを受け取る関数を、Dogを受け取る関数の位置に入れられます。 - 双変 (bivariant) — 両方向とも許します。楽ですが安全ではありません。
- 不変 (invariant) — まったく同じ型しか許しません。
なぜ必要か
変性のルールがないと、型検査を平然と通ったコードが実行時に壊れます。いちばん有名な例が配列です。
const dogs: Dog[] = [new Dog()];
const animals: Animal[] = dogs; // 通ります
animals.push(new Cat()); // 通ります
dogs[1].bark(); // 💥 Cat に bark はありません
dogs と animals は同じ配列を指しています。だから animals に猫を入れると dogs の中にも猫が入ります。ところが型のうえでは dogs は犬だけが入った配列なので、bark() を呼ぶコードが通ってしまいます。
要点はこれです。読むだけなら安全で、書き込みまで許すと安全ではありません。 だから変性は「この位置が読む位置なのか書く位置なのか」に応じて、許す向きを変えて決めます。
実務でこれに最初に出会うのは、たいていコールバックを受け取るコンポーネントを作るときです。onSelect: (row: Row) => void のような prop がどんな関数まで受け入れるべきか、そしてコンパイラがそれをどれだけ厳しく検査するかが、API の安全性を左右します。
動作原理
反変がなぜひっくり返るのか
関数のパラメータ位置が反変なのは、思ったより直感的です。より広く受け取れる関数は、狭い位置に入れても安全だからです。
type DogHandler = (x: Dog) => void;
const handleAnyAnimal = (x: Animal) => console.log(x.name);
const handleOnlyDog = (x: Dog) => x.bark();
const h1: DogHandler = handleAnyAnimal; // ✅ 犬を渡しても問題ありません
const h2: (x: Animal) => void = handleOnlyDog; // ❌ 猫を渡すと bark() がありません
handleAnyAnimal はどんな動物も処理できるので、犬しか来ない位置に置いても何の問題もありません。逆に handleOnlyDog をどんな動物でも来る位置に置くと、猫が来たときに壊れます。
つまりパラメータは広いほど安全で、戻り値は狭いほど安全です。だから戻り値の型は共変、パラメータの型は反変になります。
位置ごとの変性まとめ
| 位置 | 変性 | 理由 |
|---|---|---|
| 関数の戻り値の型 | 共変 | 受け取る側は読むだけです |
| 関数のパラメータの型 | 反変 | 渡す側は書くだけです |
readonly プロパティ / readonly T[] | 共変 | 読むだけなので安全です |
読み書きするプロパティ / T[] | 理論上は不変 | TypeScript は便宜上、共変として扱います |
| メソッド短縮記法のパラメータ | 双変 | 利便性のための意図的な例外です |
配列は理論上は不変なのに TypeScript が共変として扱っている、これが上で見た事故の原因です。これはバグではなく、意図的に受け入れた不健全さです。配列を厳密に検査すると、実用的なコードのほとんどが通らなくなるからです。
strictFunctionTypes が塞げない位置
strictFunctionTypes オプションを有効にすると、関数型のパラメータが反変として厳密に検査されます。ところがメソッド短縮記法はこのオプションの対象外です。
interface WithMethod {
on(cb: (e: Dog) => void): void; // メソッド短縮記法 → 双変、ゆるいです
}
interface WithProperty {
on: (cb: (e: Dog) => void) => void; // 関数プロパティ → 反変、厳密です
}
2 つの宣言は読んだ感じほとんど同じなのに、検査の強さが違います。構文の一文字の差で保護がついたり消えたりするわけです。
この例外があるのは、Array.prototype.push のように標準ライブラリのメソッドの多くが厳密な検査を通らないからです。互換性のために残された穴です。
実務への適用
読み取り専用で共変性の穴を塞ぐ
いちばん安上がりで効果の高い方法です。
// 危険 — 受け取った側が要素を追加できます
function render(items: Item[]) { /* ... */ }
// 安全 — 書き込み操作そのものが型にありません
function render(items: readonly Item[]) { /* ... */ }
readonly T[] には push も splice もありません。共変性が不健全だった理由が「書き込み」だったので、書き込みをなくせば問題は消えます。
コールバックは関数プロパティで受け取る
// ゆるい — 間違ったハンドラが通ってしまいます
interface Props {
onSelect(row: Row): void;
}
// 厳密 — パラメータが反変として検査されます
interface Props {
onSelect: (row: Row) => void;
}
公開 API では、安全性が重要なコールバックは関数プロパティとして宣言します。
変性を自分で宣言する
TypeScript 4.7 からは、ジェネリックパラメータに in(反変)/ out(共変)を付けて意図を固定できます。
interface Producer<out T> { get(): T }
interface Consumer<in T> { set(value: T): void }
interface Store<in out T> { get(): T; set(value: T): void }
推論を置き換えるものではなく、意図を明示して検証してもらうためのものです。付け方を間違えると、コンパイラが「この位置はその変性ではない」と教えてくれます。型が大きいライブラリでは推論コストを減らす効果もあります。
変性に頼らない制約を選ぶ
型制約を使うとき、それが通る理由が「コンパイラがこのフィールドを反変と判定するから」だと危険です。フィールドをメソッド短縮記法に変えた瞬間に双変性の例外が適用され、根拠が崩れてしまうからです。
// 変性の判定に依存しています — 構文が変わると静かに崩れます
function f<T extends readonly ColumnDef<never>[]>(cols: T) {}
// 構造だけを要求します — 構文に関係なく安定です
function f<T extends readonly { readonly id: string }[]>(cols: T) {}
トレードオフ
厳密にすればするほど、通るコードが減ります。 すべてを不変にすれば理論上は完璧ですが、実用的なコードのほとんどが通らなくなって誰も使いません。TypeScript が配列を共変のままにし、メソッドに双変性の例外を残したのは、このバランスを選んだ結果です。
readonly を全面的に導入すれば安全性は確実に上がりますが、既存のコードベースに入れると修正箇所がかなり広く散らばります。新しく作る公開 API から適用して、少しずつ広げていくのが現実的です。
in/out アノテーションは意図を明確にしますが、付けた瞬間にそのジェネリックの使いどころが制約されます。あとで戻り値にも使いたくなったとき、in が足かせになります。長く維持するライブラリの型にだけ使うのがよいです。
使ってはいけない場合
- アプリ内部の短いヘルパー関数。 変性まで考えて設計する必要はありません。呼び出し箇所がいくつかで、全部同じファイルにあるなら、素直に具体型で書くほうが読みやすくて安全です。
- 変性だけで実行時の安全を保証しようとすること。 変性はコンパイル時のルールです。API レスポンスや
localStorageの値のように外から入ってくるデータは、変性とは関係なく実行時のパーサで検査しなければいけません。 - 双変性の例外を回避するために型を複雑にすること。 メソッド短縮記法がゆるいのが問題なら、答えは複雑なジェネリックではなく関数プロパティに変えることです。
よくある間違い
Dog[]をAnimal[]として渡し、受け取った側で追加する。 いちばん古典的な事故です。渡すときにreadonlyを付ければ、そもそも起きません。strictFunctionTypesを有効にしたからコールバックは安全だと信じる。 メソッド短縮記法で宣言していたら、そのオプションは適用されません。宣言の構文を自分で確認する必要があります。- 変性の判定に頼る制約を使う。 通る根拠がコンパイラの判定だと、構文が変わったときにエラーも出ずに保護だけが消えます。
in/outを性能目的だけでどこにでも付ける。 アノテーションは契約です。あとでそのジェネリックを別の位置で使おうとしたときに詰まります。- 型が通ったから動作も正しいと報告する。 変性は安全性の証明ではなく、安上がりなフィルタです。TypeScript は意図的に不健全な部分を持っています。
関連概念
- derive-types-from-values — 値から型を派生させるとき、変性に頼らない制約を選ぶ理由
- generic-context-factory — ジェネリックなコンテキストにコールバックを載せるときの、パラメータ位置の検査の強さ
- type-level-testing — 変性の判定が実際に誤用を防いでいるかを、コンパイラの証拠で確認する