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

変位(Variance)の深淵:なぜ配列と関数で型安全性の挙動が反転するのか

TypeScriptの型システムは、一見すると直感的だ。`string` は `string | number` の部分型(subtype)であり、より広い型へと安全に値をアップキャストできる。しかし、この「部分型関係が複合型においてどのように継承されるか」という問い――すなわち変位(Variance)の領域に踏み込んだ途端、多くのエンジニアがコンパイラの冷徹な型エラーに直面する。

本稿では、配列(Array)と関数(Function)という身近な構造を題材に、TypeScriptコンパイラが裏側でどのような型評価を行っているのか、そしてなぜランタイムの安全性と静的解析の矛盾を防ぐために「共変(Covariant)」と「反変(Contravariant)」という双対性が必要なのかを、極限の低レイヤ視点から解き明かす。

—

1. 変位(Variance)とは何か:型システムの幾何学

変位とは、型コンストラクタ(`Array` や `(arg: T) => R` など)が、型引数 `T` の部分型関係をどのように「維持するのか、あるいは反転させるのか」を定義する規則である。

型 $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 = (event: T) => void;

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`, `ReturnType` など)を自在に操り、コンパイルエラーに怯えることのない極限の型安全性を実現できる。

型エラーに直面したとき、焦る必要はない。コンパイラはただ、あなたのコードがランタイムの断崖絶壁から落ちないよう、冷徹に数学の定理を突きつけているだけなのだから。

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