変位(Variance)の深淵:なぜ配列と関数で型安全性の挙動が反転するのか
TypeScriptの型システムは、一見すると直感的だ。`string` は `string | number` の部分型(subtype)であり、より広い型へと安全に値をアップキャストできる。しかし、この「部分型関係が複合型においてどのように継承されるか」という問い――すなわち変位(Variance)の領域に踏み込んだ途端、多くのエンジニアがコンパイラの冷徹な型エラーに直面する。
本稿では、配列(Array)と関数(Function)という身近な構造を題材に、TypeScriptコンパイラが裏側でどのような型評価を行っているのか、そしてなぜランタイムの安全性と静的解析の矛盾を防ぐために「共変(Covariant)」と「反変(Contravariant)」という双対性が必要なのかを、極限の低レイヤ視点から解き明かす。
—
1. 変位(Variance)とは何か:型システムの幾何学
変位とは、型コンストラクタ(`Array
型 $A$ が型 $B$ の部分型であるとき(これを $A \le B$ と表記する)、型コンストラクタ $F$ を適用した $F$ と $F$ の間にどのような大小関係が成り立つかによって、変位は4つに分類される。
1. 共変(Covariant): $A \le B \implies F \le F$ (方向が一致)
2. 反変(Contravariant): $A \le B \implies F \le F$ (方向が逆転)
3. 双変(Bivariant): 両方向が同時に成り立つ(TypeScriptのメソッド構文など)
4. 不変(Invariant): どちらの方向も成り立たない(完全一致のみ)
この数学的とも言える規則が、実務のコードベースでどのようなセキュリティホークやランタイムクラッシュを防いでいるのかを見ていこう。
—
2. 配列の罠:なぜ `Array` を `Array` に代入できないのか
オブジェクト指向言語の経験者によれば、「犬(Dog)は動物(Animal)の一種なのだから、犬の配列は動物の配列として扱えるはずだ(共変性)」と思いがちである。実際、Javaなどの言語では配列は共変として設計され、ランタイムエラーの温床となってきた。
TypeScript(`–strict` モード)において、配列は共変として扱われる。しかし、ここに落とし穴がある。
class Animal {
name: string = “Animal”;
}
class Dog extends Animal {
bark() { console.log(“Woof!”); }
}
class Cat extends Animal {
meow() { console.log(“Meow!”); }
}
let dogs: Dog[] = [new Dog()];
let animals: Animal[] = dogs; // コンパイルエラーにならない(–strict下での挙動については後述)
正確を期すならば、TypeScriptの標準配列は `readonly` でない限り、完全に安全とは言えないケースが存在する。もし配列が完全な共変であり、かつミュータブル(書き換え可能)であると仮定すると、何が起こるか。
let dogs: Dog[] = [new Dog()];
// もしここで animals: Animal[] = dogs が通ると仮定する(共変の仮定)
let animals: Animal[] = dogs;
// Animal の配列なのだから、Cat を代入しても静的にはエラーにならないはず…?
animals[0] = new Cat();
// 恐ろしいことに、参照元である dogs[0] は実際には Cat インスタンスを指している!
dogs[0].bark(); // ランタイムエラー: dogs[0].bark is not a function
このメモリ破壊、あるいは型安全性崩壊を防ぐため、TypeScriptでは構造的型付けとミュータビリティのバランスをとっている。
実際のTypeScriptにおいて、`–strictFunctionTypes` および適切な配列の評価において、コンパイラは配列の書き込み操作(`push`, インデクサー代入)を考慮し、厳密な型チェックを行う。
ここで注目すべきは、「読み取り専用(Readonly)」であれば共変は完全に安全であるという点だ。
let dogs: readonly Dog[] = [new Dog()];
let animals: readonly Animal[] = dogs; // 完全無欠に安全!
// animals[0] = new Cat(); // エラー: readonly のため代入不可
コンパイラは `readonly` 修飾子を見ることで、「このメモリ領域は読み出し専用であり、異物の混入(Catの挿入)があり得ない」と判断し、共変性を安全に許可する。これがメモリ最適化と型安全性の両立の妙である。
—
3. 関数の反変性(Contravariant):なぜ引数の型は「広く」なければならないのか
配列のモヤモヤを抜けたエンジニアをさらに混乱させるのが、関数の引数における「反変性」だ。
関数型 `(arg: T) => R` において、戻り値の型 `R` は共変だが、引数の型 `T` は反変である。つまり、引数の型は「狭いものから広いものへ」ではなく、「広いものから狭いものへ」しか代入できない。
コードで証明しよう。
type AnimalProcessor = (animal: Animal) => void;
type DogProcessor = (dog: Dog) => void;
let processAnimal: AnimalProcessor = (animal: Animal) => {
console.log(animal.name);
};
// さあ、DogProcessor を要求する場所に、AnimalProcessor を渡せるか?
let processDog: DogProcessor = processAnimal; // ここに注目
一見、「動物を処理できる関数(`processAnimal`)」は、「犬を処理できる関数(`processDog`)」の要件を完全に満たしているように見える。実際、犬を渡されても動物として処理できるため、これは安全である。
逆はどうだろうか?
let processDogStrict: DogProcessor = (dog: Dog) => {
dog.bark(); // Dog 専用のメソッドを呼び出す
};
// DogProcessor を要求されている場所に、AnimalProcessor を代入できるか?
let invalidProcessor: AnimalProcessor = processDogStrict;
// コンパイルエラー(–strictFunctionTypes 有効時)
もしこれが許可されたとしよう。`invalidProcessor` は `AnimalProcessor` 型なので、呼び出し元は `Cat`(Animalの一種)を渡す可能性がある。
let cat = new Cat();
invalidProcessor(cat); // 内部で dog.bark() が呼ばれるが、Cat に bark は存在しない!
したがって、「特定のサブタイプ(Dog)しか処理できない関数を、より広範なスーパタイプ(Animal)を処理すべき場所に代入してはならない」。
逆に、スーパタイプを処理できる汎用的な関数は、より限定されたサブタイプの要求を満たすことができる。
これが、関数の引数における反変性(Contravariant)の正体である。
—
4. チーフアーキテクトが教える:コンパイラ内部の型評価と実務での防壁
現代の大規模フロントエンドやNode.jsのバックエンドにおいて、この変位の理解は単なる理論的遊戯ではない。高度な抽象化レイヤやプラグインアーキテクチャを構築する際の「型アサーションのバグ」や「意図しない型抜け」を防ぐ防壁となる。
メモリとイベントループの文脈における型安全性
Node.jsのイベントループ上で動作する非同期パイプラインや、高頻度でイベントを発火するEventEmitterを設計する際、コールバック関数の型定義を誤ると、ランタイムのキュー消費時に予期せぬオブジェクトが流れ込み、V8エンジンのインラインキャッシュ(IC)を汚染・破壊する原因となる。
// イベントリスナーの安全な抽象化
type EventHandler
class EventBus
private handlers: EventHandler
// 登録時は反変性を利用して、より広範なイベントを受け入れ可能なハンドラも許容すべきか?
// いや、イベントバス自体の型パラメータ T に対してリスナーの引数は反変の位置にある。
public subscribe(handler: EventHandler): void {
// ここで変位の制約を正しく理解していないと、リスナーの型安全性が崩壊する
}
}
TypeScriptコンパイラは、型チェックの際に関数プロパティを評価する際、デフォルトでは双変(Bivariant)として扱う仕様(特にオブジェクトのメソッド記法 `method(arg: T)`)をとることがある。しかし、プロダクション環境の厳格性を担保するためには、常に `tsconfig.json` で以下のフラグを有効化していなければならない。
{
“compilerOptions”: {
“strict”: true,
“strictFunctionTypes”: true
}
}
`strictFunctionTypes: true` を有効にすることで、関数の引数が厳密に反変としてチェックされ、曖昧な型安全性の穴が塞がれる。プロのアーキテクトであれば、このフラグなしでのコードベース運用など論外であると知っているはずだ。
—
5. まとめ
TypeScriptの型システムは、単なる「補完のためのツール」ではない。それは静的な空間における厳密な数学的論理であり、ランタイムの物理法則(V8の挙動やメモリレイアウト)と直結した防壁である。
- 配列(ミュータブル)はそのままでは不変、読み取り専用(`readonly`)であれば共変。
- 関数型の戻り値は共変、引数は反変。
この変位の双対性を脳内に焼き付けた者だけが、複雑怪奇なジェネリクスや高度なユーティリティ型(`Parameters
型エラーに直面したとき、焦る必要はない。コンパイラはただ、あなたのコードがランタイムの断崖絶壁から落ちないよう、冷徹に数学の定理を突きつけているだけなのだから。