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

開発チームの皆さん、お疲れ様です。テクニカルリードの私だ。

今回のコードレビューで、幾人かのジュニア・ミドル層のエンジニアが「なぜか配列の代入でコンパイルエラーになる」「コールバック関数の型定義で意図せぬ型不一致が起きる」という壁にぶつかっていた。

エラーメッセージを表面上だけ直して「動いたから良し」とするのは、プロのエンジニアとして最も避けるべき悪手だ。TypeScriptの型システムが裏側でどう評価されているか、その「重み」を理解していなければ、コンポーネント設計や非同期API連携の根幹で必ず致命的なバグを踏み抜くことになる。

今回は、TypeScriptの型推論における「変位(Variance:共変性と反変性)」の核心に迫る。なぜ配列と関数で代入の可否が逆転するのか、実務のコードベースでどう安全性を担保すべきか、その全貌をロジカルかつシャープに伝授しよう。

—

1. 型の互換性を支配する「変位(Variance)」とは何か?

まず大前提として、TypeScriptの型システムは「構造的型付け(Structural Subtyping)」をベースにしている。
「ある型 $A$ が別の型 $B$ の要件を完全に満たしているなら、$A$ は $B$ のサブタイプであり、代入可能である」という原則だ。

例えば、`Dog` が `Animal` のサブタイプ(より具体的)である場合、以下の代入は当然成立する。

interface Animal { name: string }
interface Dog extends Animal { bark(): void }

let animal: Animal;
let dog: Dog = { name: “Rex”, bark: () => console.log(“Woof”) };

animal = dog; // 完全に安全。サブタイプはスーパータイプに代入可能(共変)

しかし、これが配列や関数といった「複雑な構造を持つ型(コンテナ型)」に包まれた瞬間、話は一変する。ここに「変位(Variance)」という概念が絡んでくる。

変位には主に以下の4つが存在するが、今回は実務で最も直結する「共変性(Covariance)」と「反変性(Contravariance)」に絞って解説する。

—

2. 配列の罠:なぜ「共変」なのにバグが起きるのか?

まずは配列だ。TypeScriptにおいて、配列(`T[]`)は共変(Covariant)として扱われる。
つまり、「`Dog` は `Animal` のサブタイプであるならば、`Dog[]` は `Animal[]` のサブタイプである」と推論される。

コードで見てみよう。

const dogs: Dog[] = [{ name: “Rex”, bark: () => {} }];

// コンパイルは通る(配列は共変)
const animals: Animal[] = dogs;

// さあ、ここで何が起きるか?
animals.push({ name: “Generic Cat” }); // CatはAnimalだが、Dogではない!

// 破滅のランタイムエラー
dogs[0].bark(); // 💥 実行時エラー: dogs[0].bark is not a function

なぜTypeScriptはこの危険な代入を許容するのか?

TypeScriptの初期設計において、配列の読み取り専用性を強制する仕組みがデフォルトではなかったこと、そして実用上の利便性(UIコンポーネントのリスト渡しの容易さなど)から、配列は共変として設計された。

しかし、これは「読み取り専用(Read-only)」として使う場合にのみ安全な性質だ。要素の「書き込み(代入)」が発生した瞬間、型の安全性は崩壊する。

【実務での設計パターン】ReadonlyArrayによる安全性の強制

プロダクションコードにおいて、不特定多数に配列を渡す場合や、Redux/Zustand等のステート管理でイミュータブル性を担保したい場合は、必ず `readonly` または `ReadonlyArray` を使用せよ。

// ❌ 危険な設計:意図せず書き換えられる余地を残す
function renderAnimalList(animals: Animal[]) {
// ここで誤ってanimals.push()できてしまう
}

// ⭕️ 堅牢な設計:読み取り専用に絞り、共変の安全な恩恵だけを受ける
function renderAnimalListSafe(animals: ReadonlyArray) {
// animals.push(…) はコンパイルエラーになるため、安全!
animals.forEach(animal => console.log(animal.name));
}

—

3. 関数の罠:なぜ「反変」でなければならないのか?

次に、フロントエンドの非同期API連携やイベントハンドラ設計で最も頻出する「関数型」の変位についてだ。
関数は、引数に対しては「反変(Contravariant)」、戻り値に対しては「共変(Covariant)」という、非常に直感に反する性質を持つ。

まずは結論のコードを見てほしい。

let fn: (animal: Animal) => void;

// 以下の代入は許可されるか?
let dogFn = (dog: Dog) => console.log(dog.bark());

// 代入してみる
fn = dogFn; // ⚠️ ここ、TypeScriptではどう扱われるか?

実は、厳密な型チェック(`–strictFunctionTypes` が有効な場合)において、関数を別の関数へ代入する際、引数の型は「逆向き」の互換性が要求される。

なぜ引数は「反変(逆向き)」なのか?

思考実験をしよう。もし `fn = dogFn` が無条件で許可されたと仮定する。

1. 呼び出し側は、`fn` に対して 「どんな `Animal` でも受け取れる関数」 として引数を渡す。
2. 例えば、`Animal` である「猫(Cat)」を `fn` に渡してみる。
3. しかし、中身の実体は `dogFn`(`Dog` しか受け取れない関数)である。
4. `dogFn` の中で `dog.bark()` が実行された瞬間、猫には `bark` がないためランタイムエラーが起きる。

したがって、「より広い範囲を受け取れる関数(`Animal` を受け取る関数)」は、「より狭い範囲しか処理できない関数(`Dog` しか受け取れない関数)」の代わりに代入することはできない。
逆に、「`Dog` を受け取る関数」が期待されている場所に、「あらゆる `Animal` を受け取れる関数」を渡すのは完全に安全だ。引数の要求が厳しくなる分には、何が来ても処理できるからだ。

これが反変性(Contravariance)の本質である。

—

4. プロダクションコードにおける実践:APIハンドラの型設計

この変位の知識が、実務のアーキテクチャ設計でどう活きるか。
例えば、フロントエンドで共通のAPIクライアントや、イベントバス(Event Emitter)を設計するシーンを想像してほしい。

以下は、コールバックの型定義において、反変性を正しく理解していないとハマるアンチパターンと、その洗練された解決策だ。

❌ 悪い例:イベントリスナーの型定義ミス

interface UIEvent { timestamp: number; }
interface MouseClickEvent extends UIEvent { x: number; y: number; }

type EventListener = (event: T) => void;

class Component {
// リスナーを保持する内部ストレージ
private listeners: EventListener[] = [];

public register(listener: EventListener) {
this.listeners.push(listener);
}

public trigger(event: UIEvent) {
this.listeners.forEach(l => l(event));
}
}

// 現場での使用
const comp = new Component();

// 「MouseClickEvent専用のハンドラ」を登録したい!
const clickHandler = (e: MouseClickEvent) => {
console.log(`Clicked at ${e.x}, ${e.y}`);
};

// 💥 コンパイルエラー!
// 理由: EventListener を要求する場所に EventListener を渡そうとしたが、
// 関数の引数は反変であるため、UIEventを受け取れる関数でなければ登録できない。
comp.register(clickHandler);

⭕️ 完璧な解決策:コントravariant(反変)を考慮した柔軟な型制約

この問題をスマートに解決するには、ジェネリクスを用いて「受け入れるイベントの型」を適切に抽象化し、関数型の代入可能性をコンパイラに正しく伝える必要がある。

interface UIEvent { timestamp: number; }
interface MouseClickEvent extends UIEvent { x: number; y: number; }

// イベントリスナーは「Tのスーパータイプ(Tを含むより広い範囲)」を受け取れる関数であってもよい
// あるいは、イベントの送信側と受信側の型制約をコールバック側で柔軟に受けるようにする
class EventBus {
private listeners: ((event: TEvent) => void)[] = [];

// 💡 ここがポイント:リスナーは TEvent を「処理できる(あるいはそれ以上の汎用的な)」関数を受け入れる
public subscribe(listener: (event: TSub) => void) {
// 共変・反変の制約を回避しつつ、安全にキャストして保持する
this.listeners.push(listener as unknown as (event: TEvent) => void);
}

public publish(event: TEvent) {
this.listeners.forEach(listener => listener(event));
}
}

// 実装例
const mouseBus = new EventBus();

// MouseClickEvent だけでなく、汎用的な UIEvent を処理するハンドラも登録可能になる!
const genericHandler = (e: UIEvent) => {
console.log(`Event at: ${e.timestamp}`);
};

const preciseHandler = (e: MouseClickEvent) => {
console.log(`Precise click: ${e.x}, ${e.y}`);
};

mouseBus.subscribe(genericHandler); // 綺麗に通る!
mouseBus.subscribe(preciseHandler); // もちろん通る!

—

5. 本日のまとめ:リードからのメッセージ

TypeScriptの型推論における変位を制する者は、TypeScriptの型システムを制する。今日の要点を最後に頭に叩き込んでおいてほしい。

1. 配列(`T[]`)は共変(Covariant)である。

  • 読み取り専用としては安全だが、書き込みを伴う場合は `ReadonlyArray` を活用し、ランタイムの型安全性を担保せよ。

2. 関数は「引数が反変(Contravariant)」、「戻り値が共変(Covariant)」である。

  • コールバック関数やイベントハンドラを設計する際は、引数の型の階層(サブタイプ・スーパータイプ)の向きに細心の注意を払え。

3. 「動くからいいや」の先にある負債を断ち切れ。

  • コンパイラの挙動をロジカルに予測できるようになると、無駄な `as any` や型アサーションを撲滅でき、真に保守性の高いコードベースが手に入る。

明日のコードレビューでは、今日学んだ「変位」の視点をチームメンバーのプルリクエストにもぜひ波及させてくれ。期待している。

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