8
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[TypeScriptシリーズ - Part 4] Variance

8
Posted at

c75e466c-35fa-4648-8905-a5745aad6f33-clean.png

📝 注記
私は日本語が得意ではありません。この記事はAIのサポートを受けて書いています。ご了承ください。

📖 目次

  1. 問題の提示 – どんな時にこのテクニックが必要か
  2. 悪い例 – まずはダメなコードを見せる
  3. 良い例 – TypeScriptの高度機能で解決する
  4. Playgroundリンク – その場で試せる
  5. 課題 – シニア向けのチャレンジ問題
  6. まとめ

1. 問題の提示 – どんな時にこのテクニックが必要か

あなたは汎用的なイベントハンドリングシステムを設計しています。

class EventEmitter<T> {
  private handlers: ((event: T) => void)[] = [];

  on(handler: (event: T) => void) {
    this.handlers.push(handler);
  }

  emit(event: T) {
    this.handlers.forEach(h => h(event));
  }
}

問題点:

  • EventEmitter<MouseEvent>EventEmitter<Event> として使いたい場合がある
  • しかし、TypeScriptはこれを許可するのか?しないのか?
  • なぜ MouseEvent[]Event[] に代入できるのに、EventEmitter<MouseEvent>EventEmitter<Event> に代入できないのか?
  • 関数の引数型はなぜ逆の方向になるのか?

問いかけ:

Variance(分散) を理解すれば、これらの疑問にすべて答えられます。


2. 悪い例 – まずはダメなコードを見せる

// ❌ Varianceを理解していないと起こる問題

class Animal {
  name = "animal";
}

class Cat extends Animal {
  meow() { console.log("meow"); }
}

class Dog extends Animal {
  bark() { console.log("bark"); }
}

// 問題1: 配列の共変性(Covariance)による実行時エラー
const cats: Cat[] = [new Cat()];
const animals: Animal[] = cats;  // TypeScriptはこれを許可する!

animals.push(new Dog());         // ⚠️ コンパイルは通るが...
const myCat: Cat = cats[1];      // 💥 実行時エラー: DogをCatとして扱う

// 問題2: 関数パラメータの反変性(Contravariance)を無視した設計
type Handler<T> = (value: T) => void;

const handleAnimal: Handler<Animal> = (a: Animal) => {
  console.log(a.name);
};

const handleCat: Handler<Cat> = (c: Cat) => {
  c.meow();
};

const badHandler: Handler<Cat> = handleAnimal;   // これは安全
const worseHandler: Handler<Animal> = handleCat; // 💥 危険!

// 問題3: ミュータブルなジェネリック型
class Box<T> {
  value: T;
  constructor(value: T) { this.value = value; }
  set(newValue: T) { this.value = newValue; }
  get(): T { return this.value; }
}

const catBox = new Box<Cat>(new Cat());
const animalBox: Box<Animal> = catBox;  // 理論的には危険
// animalBox.set(new Dog());            // もし許可されたら大惨事

なぜ悪いのか:

問題 説明
配列の共変性 ミュータブルな配列を共変にすると、型安全性が崩れる
関数パラメータの双変性 安全でない方向の代入を許可する場合がある
Variance無視の設計 ジェネリック型の正しい分散関係を考慮しないとAPIが危険

3. 良い例 – TypeScriptの高度機能で解決する

基本: Varianceとは?

Variance は、Cat <: Animal(CatはAnimalのサブタイプ)という関係が、ジェネリック型 F<T> にどのように伝播するかを決めるルールです。

// 前提: Cat は Animal のサブタイプ
// Cat <: Animal

// 1. 共変 (Covariant):     F<Cat> <: F<Animal>  (同じ方向)
// 2. 反変 (Contravariant): F<Animal> <: F<Cat>  (逆の方向)
// 3. 不変 (Invariant):     F<Cat> と F<Animal> に関係なし
// 4. 双変 (Bivariant):     両方向の代入を許可

ユースケース1: 読み取り専用 → 共変(Covariant)

// ✅ 読み取り専用のコンテナは共変で安全
type ReadonlyBox<T> = {
  readonly value: T;
};

const catBox: ReadonlyBox<Cat> = { value: new Cat() };

// Cat用の箱をAnimal用として使える
const animalBox: ReadonlyBox<Animal> = catBox;  // ✅ OK

console.log(animalBox.value.name);  // "animal"
// animalBox.value = new Animal();  // ❌ 読み取り専用なので代入できない

処理の流れ(視覚モデル):

Cat ───────────────→ Animal
 │                      │
 ▼                      ▼
ReadonlyBox<Cat> ──→ ReadonlyBox<Animal>   (共変・同じ方向)

ユースケース2: 関数パラメータ → 反変(Contravariant)

// ✅ ハンドラ型は反変で安全
type Handler<T> = (value: T) => void;

const handleAnimal: Handler<Animal> = (animal: Animal) => {
  console.log(animal.name);
};

// Animal用ハンドラをCat用として使える
const handleCat: Handler<Cat> = handleAnimal;  // ✅ OK

// Cat専用ハンドラはAnimal用として使えない(安全でないため)
// const handleAnimal2: Handler<Animal> = handleCat;  // ❌ エラー

handleCat(new Cat());  // handleAnimalはCatを受け取れる

処理の流れ(視覚モデル):

Cat ───────────────→ Animal
 ↑                      ↑
 │                      │
Handler<Cat> ←──── Handler<Animal>   (反変・逆の方向)

ユースケース3: ミュータブルコンテナ → 不変(Invariant)

// ⚠️ ミュータブルなコンテナは本来「不変」であるべき
// しかしTypeScriptは配列を共変として扱う(妥協)

interface ImmutableBox<T> {
  readonly get: () => T;
}

interface MutableBox<T> {
  get: () => T;
  set: (value: T) => void;
}

// T[] は読み書き両方可能だが、共変として扱われる(危険)

ユースケース4: 実際のTypeScriptの挙動

// 1. 配列: 共変として扱う(安全性より実用性)
const cats: Cat[] = [new Cat()];
const animals: Animal[] = cats;  // ⚠️ 許可される(理論的には危険)

// 2. strictFunctionTypes モード(tsconfig.json: { "strictFunctionTypes": true })
type Handler<T> = (value: T) => void;

const handleAnimal: Handler<Animal> = (a) => console.log(a.name);
const handleCat: Handler<Cat> = handleAnimal;    // ✅ 許可(反変)

const handleAnimal2: Handler<Animal> = handleCat; // ❌ エラー(危険な方向は禁止)

// 3. メソッド構文は双変(歴史的理由)
interface EventSource {
  on(event: string, handler: (event: Event) => void): void;
}

ユースケース5: Reactの型定義に見るVariance

// ReactElementはイミュータブルなので共変
type ReactElement<Props> = {
  type: string;
  props: Props;
};

type CatProps    = { name: string; meow: () => void };
type AnimalProps = { name: string };

// CatProps用の要素をAnimalProps用として扱える
const catElement: ReactElement<CatProps>       = { type: "Cat", props: { name: "Tom", meow: () => {} } };
const animalElement: ReactElement<AnimalProps> = catElement;  // ✅ 安全

// RefSetter は反変
type RefSetter<T> = (instance: T | null) => void;

const setDiv: RefSetter<HTMLDivElement> = (el) => {
  if (el) el.style.color = "red";
};

const setElement: RefSetter<HTMLElement> = setDiv;  // ✅ 安全

Varianceの決定ガイド

// ルール1: 読み取り専用 → 共変
type Producer<T> = { get: () => T };

// ルール2: 書き込み専用 → 反変
type Consumer<T> = { set: (value: T) => void };

// ルール3: 読み書き両方 → 不変
type Container<T> = { get: () => T; set: (value: T) => void };

// ルール4: 関数の戻り値 → 共変
type FuncReturn<T> = () => T;

// ルール5: 関数の引数 → 反変
type FuncParam<T> = (arg: T) => void;

4. Playgroundリンク – その場で試せる

理論だけでは実感しにくいので、実際に動かして確認してみましょう。
TypeScript Playgroundはブラウザ上でTypeScriptを実行できる公式ツールです。インストール不要、すぐに試せます。

🔗 Playground URL: https://www.typescriptlang.org/play/

何を確認できるのか?

下のコードをコピーしてPlaygroundに貼り付けた後、❌ エラー とコメントされた行のコメントアウトを外してみてください。TypeScriptが実際にエラーを出すことが確認できます。また ✅ OK の行はエラーにならないことも確認できます。

// ① このコードをコピーしてPlaygroundに貼り付ける
class Animal { name = "animal"; }
class Cat extends Animal { meow() { console.log("meow"); } }
class Dog extends Animal { bark() { console.log("bark"); } }

// 1. 共変 (Covariant) - 読み取り専用
type ReadonlyBox<T> = { readonly value: T };
const catBox: ReadonlyBox<Cat> = { value: new Cat() };
const animalBox: ReadonlyBox<Animal> = catBox;  // ✅ OK

// 2. 反変 (Contravariant) - 関数引数
type Handler<T> = (value: T) => void;
const handleAnimal: Handler<Animal> = (a) => console.log(a.name);
const handleCat: Handler<Cat> = handleAnimal;           // ✅ OK
// const handleAnimal2: Handler<Animal> = handleCat;   // ❌ コメントを外すとエラー

// 3. 不変 (Invariant) - ミュータブルコンテナ
interface Box<T> {
  get: () => T;
  set: (value: T) => void;
}

// 4. TypeScriptの配列は共変(危険な例)
const cats: Cat[] = [new Cat()];
const animals: Animal[] = cats;    // ⚠️ 許可される
// animals.push(new Dog());        // ② コメントを外して実行すると実行時に壊れる

// 5. メソッド vs 関数プロパティ
interface Example {
  method(param: Animal): void;       // メソッド: 双変
  property: (param: Animal) => void; // プロパティ: 反変(strict下で)
}

ホバーすると何が見える?

handleCat にホバーすると Handler<Cat> と表示され、handleAnimal が正しく代入されていることが確認できます。コメントアウトを外した行では赤いエラーが表示され、反変性のルールが機能していることが視覚的に分かります。


5. 課題 – シニア向けのチャレンジ問題

課題1: Varianceの識別

以下の各型について、Cat <: Animal のときの分散関係を答えてください。

type A<T> = T[];
type B<T> = (t: T) => void;
type C<T> = { value: T };
type D<T> = { get: () => T; set: (t: T) => void };
type E<T> = { readonly value: T };
type F<T> = () => T;
✅ 解答を見る(クリック)
// A<T>: 共変(TypeScriptの実装上。理論的には不変であるべき)
// B<T>: 反変
// C<T>: 理論上は不変だが、TypeScriptは共変として扱う
//        → { value: T } は読み書き可能だが、strictな不変チェックがないため
//          実際には Cat <: Animal のとき C<Cat> <: C<Animal> が許可される
// D<T>: 不変(get と set の両方を持つため、TypeScriptも不変として扱う)
// E<T>: 共変(readonly なので読み取り専用)
// F<T>: 共変(戻り値のみ)

課題2: 安全なHandler型の設計

以下の要件を満たす SafeHandler<T> 型を設計してください。

  • イベントハンドラとして使用
  • より広い型のハンドラをより狭い型の位置に代入可能
  • 逆方向の代入はコンパイルエラー

💡 ヒント: メソッド構文ではなく関数プロパティ構文で定義します。

✅ 解答を見る(クリック)
// 関数プロパティとして定義(メソッド構文は避ける)
type SafeHandler<T> = (event: T) => void;

const handleAnimal: SafeHandler<Animal> = (a) => console.log(a.name);
const handleCat: SafeHandler<Cat> = handleAnimal;  // ✅ 安全

// 危険な方向はエラー(strictFunctionTypesが有効な場合)
// const handleAnimal2: SafeHandler<Animal> = handleCat;  // ❌ エラー

課題3: イミュータブルなコレクション型

ImmutableList<T> を実装し、共変になるように設計してください。

💡 ヒント: readonly を使い、書き込みメソッドを持たせないことで共変にできます。

✅ 解答を見る(クリック)
interface ImmutableList<T> {
  readonly items: readonly T[];
  readonly length: number;
  get(index: number): T;
  map<U>(fn: (value: T) => U): ImmutableList<U>;
  filter(pred: (value: T) => boolean): ImmutableList<T>;
}

class ImmutableListImpl<T> implements ImmutableList<T> {
  constructor(public readonly items: readonly T[]) {}
  get length() { return this.items.length; }
  get(index: number) { return this.items[index]; }
  map<U>(fn: (value: T) => U): ImmutableList<U> {
    return new ImmutableListImpl(this.items.map(fn));
  }
  filter(pred: (value: T) => boolean): ImmutableList<T> {
    return new ImmutableListImpl(this.items.filter(pred));
  }
}

// 共変性の確認
const cats = new ImmutableListImpl([new Cat()]);
const animals: ImmutableList<Animal> = cats;  // ✅ 共変なので安全

課題4(ボーナス): 関数の戻り値型と引数型のVariance

なぜ関数の戻り値型は共変で、引数型は反変なのか、具体例を使って説明してください。

✅ 解答を見る(クリック)
// 戻り値型(共変): より具体的な型を返せる
type Producer<T> = () => T;

const getCat: Producer<Cat>       = () => new Cat();
const getAnimal: Producer<Animal> = getCat;  // ✅ CatはAnimalなので安全

// 引数型(反変): より広い型を受け取れる
type Consumer<T> = (arg: T) => void;

const consumeAnimal: Consumer<Animal> = (a) => console.log(a.name);
const consumeCat: Consumer<Cat>       = consumeAnimal;  // ✅ AnimalはCatを受け取れる

// なぜこうなるか?
// - 戻り値: 約束する型より具体的なものを返せば問題ない
// - 引数:   必要以上の情報を受け取っても問題ない(無視すればいい)

6. まとめ

今日学んだこと

Variance 方向 安全な用途 TypeScriptの実際
共変 (Covariant) F<Cat> <: F<Animal> 読み取り専用、イミュータブル 配列は共変(危険)、readonly配列は安全
反変 (Contravariant) F<Animal> <: F<Cat> 関数引数、コールバック strictFunctionTypesで正しく動作
不変 (Invariant) 関係なし ミュータブルコンテナ デフォルトの動作
双変 (Bivariant) 両方向 ほとんどない メソッド構文で発生(歴史的理由)

シニアへのアドバイス

Varianceを理解すれば、型システムの「なぜ?」に答えられます。

  • 読み取り専用には readonly を使い、共変性を安全に活用する
  • コールバック型は関数プロパティ(type Handler<T> = (t: T) => void)で定義し、メソッド構文を避ける
  • strictFunctionTypes: true を有効にして、関数パラメータの反変性を正しく保つ
  • ミュータブルなジェネリック型を設計するときは、不変性を意識する

Have a nice day 🚀

8
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?