🧠

TypeScript / Flow で学ぶ変性:共変・反変・不変・両変を簡単に理解する

に公開

みなさん、こんにちは!

しれっとオランダに移住したフロントエンドエンジニアの @nyaomaru です!🇳🇱

TypeScript を書いていると、“変性(variance)” という言葉をなんとなく目にすると思います。

  • 共変(covariant)
  • 反変(contravariant)
  • 不変(invariant)
  • 両変(bivariant)

このあたり、なんとなく分かったような、分からないような・・・?

僕自身もずっとふわっとした理解しかなく、特に 反変両変 が直感に反していて苦しみました。

さらに、React の型定義を deep reading しようとした時に、
Flow のジェネリクス記号 +T / -T が意味不明で完全に詰みました。

export type Element<+C> = React$Element<C>;
export type RefSetter<-I> = React$RefSetter<I>;

+- って何!?」「なんで ReactFlow でこれを使ってるの?」
ここが 変性理解の入口 でした。

この記事では、TypeScriptFlow の両方を使いながら、
変性を “実務レベルで本当に使える形で” 簡単に理解することを目指します。

  • React の型を読めるようになりたい人
  • TypeScript の安全なコールバック型を作りたい人
  • bivariant の罠を避けたい人

ほんなら、一緒に見てこな!

🧩 型の変性って何なん?

「型の変性」とは、総称型(ジェネリック型)や関数型で、

型パラメータのサブタイプ関係が、外側の型にどう伝わるか

を表す概念やな。

たとえば、CatAnimal のサブタイプやけど、

  • ほんなら List<Cat>List<Animal> として扱えるんか?
  • Handler<Animal> を、Handler<Cat> の代わりに使えるんか?
  • あるいは、Box<T> みたいな「中身が mutable な型」ではどうなん?

みたいな、「ジェネリック型のサブタイプ関係」を決めるためのルールや。

ほんで変性には以下の4つがある。

  • 共変(covariant)
    • 子 → 親 に広がる
  • 反変(contravariant)
    • 親 → 子 に縮む
  • 不変(invariant)
    • どちらの方向も不可
  • 両変(bivariant)
    • どちらの方向も OKTypeScript の罠)

これらは TypeScript 固有のものやなくて、Java / C# / Kotlin / Flow みたいな、型のある言語ならどこでも登場する概念やねんな。

さて、これらの特性について個別に見ていこか〜

⬆️ 共変(covariant): 「子が OK なら親としても OK」

CatAnimal のサブタイプ(Cat <: Animal)のとき、

共変:F<Cat> <: F<Animal>

つまり、「中身がもっと具体的なもの」を「もっと抽象的なもの」として扱えるパターン。

基本的には、読み取り専用の型として利用される。

class Animal {
  name = 'animal';
}
class Cat extends Animal {
  meow() {}
}

type ReadonlyBox<T> = {
  readonly value: T;
};

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

// 「Cat の箱」を「Animal の箱」として扱うから OK
const animalBox: ReadonlyBox<Animal> = catBox;

// 読み取りは安全(Animal として読むだけ)
// animalBox.value.meow(); // これは型エラー(Animal には meow がない)

ここでは 「中身を 読む だけ」なので、より具体的な Cat を持つ箱を、
より抽象的な Animal の箱として見ることは(理論上は)安全、というのが共変やな。

  • 上段:CatAnimal(サブタイプの依存関係)
  • 下段:ReadonlyBox<Cat>ReadonlyBox<Animal>(同じ向きに伝播 → 共変)

⬇️ 反変(contravariant): 「親が OK なら子専用にも使える」

Cat <: Animal のとき、

反変:F<Animal> <: F<Cat>

つまり、「より広いものを受け取れる関数」は「より狭いもの用としても使える」という発想。

ここで大事なのは、「反変」という概念は基本的に関数の引数で扱われるってことや。
そして、その型は書き込み専用で用いられる。
ここが直感と反してるから覚えにくいけど、「引数として扱われる値の話」と思うと理解しやすい。

class Animal {
  name = 'animal';
}
class Cat extends Animal {
  meow() {}
}

type Handler<T> = (value: T) => void;

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

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

// 反変として安全なのはこっち
// 「Animal を受け取れる関数」を、「Cat 用のハンドラ」として使う
const catHandler: Handler<Cat> = handleAnimal; // OK(理論上)

// 逆方向は危険:Cat 専用ハンドラに Animal (Dog, Bird...) が来るかもしれない
// const animalHandler: Handler<Animal> = handleCat; // NG(理論上)

Handler<Animal> は「Animal ならなんでも受け取れる(Cat も含む)」から、
Handler<Cat> の代わりに使っても安全。

でも、逆は危険なのでダメ、というのが反変やな。

  • 上段:CatAnimal
  • 下段:Handler<Animal>Handler<Cat>(逆向きに伝播 → 反変)

⛔ 不変(invariant):「どっちの方向もダメ」

Cat <: Animal でも、

不変:F<Cat>F<Animal> の間にサブタイプ関係は ない

class Animal {
  name = 'animal';
}
class Cat extends Animal {
  meow() {}
}

type Box<T> = {
  value: T;
};

let animalBox: Box<Animal> = { value: new Animal() };
let catBox: Box<Cat> = { value: new Cat() };

// どっちの代入も型理論的には危険になる
// animalBox = catBox; // 本来は NG だが、TypeScript では共変寄りに扱われてコンパイルは通る
// catBox = animalBox; // NG (コンパイルエラー)

なぜか?

  • Box<Cat>Box<Animal> として扱えるとしたら:
    • Box<Animal>new Animal() を代入できてしまう
    • でも元は Box<Cat> なので、「Cat しか入らない」と思って使ってるコードが破壊される
  • 逆に Box<Animal>Box<Cat> として扱うのも同様に危険
  • そのため型理論的には「どちらの代入も NG(不変)」にしたいが、TypeScript の実装上は Box<T> のような型は実質「共変寄り」に扱われており、Box<Cat>Box<Animal> の片方向だけはコンパイルが通ってしまう

読みも書きもする mutable なコンテナは、不変にするのが安全、ってことやな。

  • 上段:CatAnimal
  • 下段:Box<Cat>Box<Animal> の間には、サブタイプとしての関連がない → 不変

🔁 両変(bivariant):「どっち向きも OK(だから危険)」 ※ TypeScript の穴

Cat <: Animal のときに、

両変:F<Cat>F<Animal>相互に代入可能

つまり 共変 + 反変のミックス。
理論的にはほぼ確実に危険やけど、TypeScript は「現実の JavaScript コードとの互換性」のために、一部のケース(特に「コールバック引数」)でこれを許してる。

type EventHandler<T> = (event: T) => void;

declare let handleEvent: EventHandler<Event>;
declare let handleMouse: EventHandler<MouseEvent>;

// 本来の型理論的にはどちらかしか許されないはずだが、
// TypeScript では「両方向とも」代入できてしまうケースがある(bivariant)
handleEvent = handleMouse; // OK(実は危険になり得る。strictFunctionTypes: true で検知可能)
handleMouse = handleEvent; // これも OK
  • MouseEventEvent のサブタイプ
  • どちら向きでも代入 OK にすると、「ハンドラが期待していないイベント」が渡される可能性が出てくる
  • でも TypeScript は「現実世界のコード量(DOM イベントハンドラなど)」を優先して、この緩さを残している
  • 上段:MouseEventEvent
  • 下段:Handler<MouseEvent>Handler<Event> の間に両方向の矢印 → 両変

🎯 変性のまとめ

簡単にまとめると、以下のような表になるで〜

種類 説明 安全性
共変 子 → 親 OK 読み取り専用 readonly T[], ReadonlyArray<T>
反変 親 → 子 OK 書き込み専用 Handler<T>
不変 双方向 NG ミュータブル Box<T>
両変 双方向 OK 危険 TypeScript の関数引数

⚙️ TypeScript の特性

ここまで扱ったのは、あくまで「型理論としての綺麗な世界」。

  • 読み取り専用なら共変
  • 引数(書き込み専用)なら反変
  • 読み書きするなら不変
  • 両方向 OK は危険(両変)

って、非常に美しいルールやねんけど

TypeScript の現実は「ちょっと違う」

実は TypeScript は、

  • JavaScript の歴史的仕様
  • 既存の膨大なコード資産
  • ブラウザ API の実装に合わせざるを得ない事情

などがあって、理論通りの変性をそのまま採用していないところが多いねんな。

典型的なのが次の 2 点:

  • 配列(T[])は本来「不変」やのに、ほぼ共変扱い
  • 関数の引数は本来「反変」やのに、両変が混ざっている

このあたりが 「型理論の感覚」と「現実の TypeScript の挙動」がズレて見える 主な理由やな。

ほんなら、この特有の変性の挙動も、一緒に見ていこか〜!

配列は本来「不変」であるべきやのに、TypeScript では“共変”として扱われる

まずは一番有名なズレ、配列(T[])の変性から見ていくで〜。

結論から言うと:

本来 T[] は「不変」にすべき
でも TypeScript はほぼ共変として扱っている
その結果、理論上は「安全でない挙動」が普通に起きる

という状態になってもうてる。

例えば、以下のような感じやな。

class Animal {
  name = 'animal';
}
class Cat extends Animal {
  meow() {}
}
class Dog extends Animal {
  bark() {}
}

const cats: Cat[] = [new Cat()];

// TypeScript 的には「Cat[] は Animal[] のサブタイプ」扱い
const animals: Animal[] = cats;

// Animal[] と見なされているから Dog を push できてしまう
animals.push(new Dog());

// でも実体は cat[]。中身が壊れてしまう。
const cat: Cat = cats[1]; // 型的には OK だが実体は Dog
cat.meow(); // 実行時エラーの可能性

なぜこれが起きるんか?

  • 型理論的には T[] は mutable container(読み書き可)
    • 本来は 不変 で扱うべき
  • でも JavaScript の配列は API がめっちゃ広くて、歴史的にも「Array は自由に代入できるもの」として扱われてきた
  • その互換性を壊すと、既存コードが大量に壊れる

つまり、実用性を優先して安全性をしゃあないした結果、T[] が共変扱いになってる。

ほんで、代わりに TS が推奨してるのは「readonly」配列

const cats: readonly Cat[] = [new Cat()];
const animals: readonly Animal[] = cats; // こっちは理論的にも安全

readonly は読み取り専用やから、共変にしても壊れへんで〜。

関数の変性:本来は「反変」なのに、TypeScript はほぼ“両変”として扱われる

配列の次にズレが大きいのが 関数の引数の変性。

結論から言うと:

理論(型システムの世界):関数の引数は反変であるべき
TypeScript(現実):けっこうな範囲で両変として扱われる

この差が
「え、なんでこれ代入できるん?」
「なんで危険なコードのコンパイル通すの?」
という混乱ポイントになってる。

そもそも、関数はなんで“反変”なのか?

type Handler<T> = (value: T) => void;

この Handler<T> の「T の位置」は

  • 値を受け取る側(=書き込み) なので
  • 本来は 反変 になる

つまり理論的にはこう:

Cat <: Animal なら

Handler<Animal> <: Handler<Cat>

前の章でやったやつやな。

実際の TypeScript:なぜか両方向に代入できる

大量の JavaScript 資産との互換のため、

「関数の引数」に関してだけ、特別に両変として扱う部分がある
strictFunctionTypes: false やメソッドの引数など、一部のケースでは)

type Handler<T> = (value: T) => void;

declare let handleEvent: Handler<Event>;
declare let handleMouse: Handler<MouseEvent>;

// 本来の型理論的にはどちらかしか許されないはずだが、
// TypeScript では「両方向とも」代入できてしまうケースがある(bivariant)
handleEvent = handleMouse; // OK(危険になり得る)
handleMouse = handleEvent; // OK(これも危険)

理論では絶対 NG なのに、TypeScript は両方向 OK にしてもうてる。

ほんで、以下のパターンはかなり危険な例やな。

type Handler<T> = (value: T) => void;

const handleEvent: Handler<Event> = (event) => {
  console.log(event.type);
};

const handleMouse: Handler<MouseEvent> = (event) => {
  // MouseEvent にしかないプロパティ
  console.log(event.clientX);
};

// 「MouseEvent 専用の handler」を「Event handler」に代入できてしまう
const eventHandler: Handler<Event> = handleMouse;

// Event が来るかもしれないのに、MouseEvent として扱ってしまう
eventHandler(new Event('click')); // ← 型は OK だが実行時エラー

これは 「本来絶対にコンパイルエラーにすべきパターン」やけど、両変のせいで通してまう。

なんで TypeScript はこんな危険な仕様になったん?

理由はめちゃくちゃ単純で:

現実の JavaScript が「厳密な反変」を前提として作られてないから。

  • DOM イベントハンドラの仕様
  • Node.js のコールバック慣習
  • JavaScript で書かれた膨大なコード

これらが「ゆるい関数代入」を前提にしてたので、

TypeScript を理論通りにすると、既存コードが破壊されまくる
工数と影響がでかすぎて無理 → しゃあない

っていう、安全性(soundness)より実用性(pragmatism)を優先したっちゅう至極真っ当な考えによるんやな。

じゃあ“厳密な反変”で関数を扱いたい時は?

実は TypeScript には裏オプションがあって、

--strictFunctionTypes をオンにすると

コールバックの引数だけ 反変寄り のチェックになる

{
  "compilerOptions": {
    "strict": true,
    "strictFunctionTypes": true
  }
}

ただし、「完全に反変」になるわけではなくて、

普通の関数型の引数は反変寄りになる一方で、メソッドの引数は依然として両変のまま

という、びみょ〜な仕様になってる点は注意やで〜。

🎯 まとめ:TypeScript は「変性」を完璧に守る世界線やない

ここまで見たように、

  • 配列(T[])は、本来は不変 → ほぼ共変
  • 関数の引数は、本来は反変 → 広い範囲で両変
  • strictFunctionTypes を ON にしても完全に反変にはならない

つまり TypeScript は、

型理論の「綺麗な世界」を完全には採用せず、
JavaScript の現実と巨大コード資産を守る設計になっている

というわけやな。

🌊 Flow では、変性がどのように表現されているのか?

Flow とは?

ここで一旦、冒頭でも出てきた Flow(FlowType) を軽くおさらいしとこか。

FlowFacebook(現 Meta)が開発した JavaScript の静的型チェッカーで、TypeScript と同じ「型を付ける」系のツールやねん。

特徴はこんな感じ:

  • 型チェックは TypeScript より厳密
  • 変性(variance)を明示的に扱う
  • 型推論がかなり強い
  • JavaScript の構文そのままに注釈を付ける

一言でいうと、

TypeScript は「実用性重視」
Flow は「安全性重視」

その安全性の象徴が、今回のテーマである 変性(variance)を明示する機能 なんや。

Flow では変性をどうやって書くん?

TypeScript と違って、Flow ではジェネリクスの型パラメータに「変性記号」を明示的に書けるようになっている(何も付けない場合は不変扱い)。

Flow の変性記号はこれや 👇

Flow の記号 意味
+T 共変(covariant)
-T 反変(contravariant)
T(記号なし) 不変(invariant)

つまり Flow では、「この型はどう使われるか?」を開発者が 明確に宣言する

TypeScript では暗黙的に推論でやってくれるけど、Flow は安全性のために明示するスタイルを取ってるわけやな。

⚛️ React での宣言はどうなってるん?

FlowReact 開発元(Meta)製ということもあって、React 本体の型定義には長らく Flow が使われてきてんな。

今もリポジトリには Flow 由来の型定義が残っていて、その名残がこれ 👇

export type Element<+C> = React$Element<C>;
export type RefSetter<-I> = React$RefSetter<I>;
  • +C共変
  • -I反変

を意味してるねん。

なんで Element の型パラメータは共変なん?

理由はシンプルで:

ReactElement は immutable(不変)な構造やし、
「要素を強い型に変換すること」はあっても、「書き込む」ことはせえへん。

だから Flow 的には +C(共変)で宣言されとるわけやな。

例えば

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

function Cat(props: CatProps) {
  return null as any;
}

const catElement: ReactElement<CatProps> = (
  <Cat name="nyaomaru" meow={() => {}} />
);

// 共変なので AnimalProps として扱っても安全
const parent: ReactElement<AnimalProps> = catElement;
  • 「より細かい型の props を持つ要素」 を
  • 「より抽象的な props の型として扱える」

これはめちゃくちゃ便利やし妥当。

React はこの「共変性」をうまく利用しているわけやな。

なんで RefSetter の型パラメータは反変なん?

理由は:

  • RefSetter は 「値(インスタンス)を受け取る側」
  • 書き込み方向だけが発生する
  • つまり、引数位置の変性は反変になる

これは前の Handler の話とも完全に対応してるな。

例えば

type HTMLDivRef = (el: HTMLDivElement | null) => void;
type HTMLElementRef = (el: HTMLElement | null) => void;

HTMLDivElement <: HTMLElement なので、反変で考えると:

RefSetter<HTMLElement> <: RefSetter<HTMLDivElement>

つまり、

  • HTML 要素ならなんでも受け取れる関数(広い受け口)
  • が、特定の ref の代わりとして安全に使える

これは正しい反変の使い方やな。

逆向きは絶対危ない:

  • 「div しか受け取れません!」という関数に
  • 「もっと広い HTMLElement」が入ってくる可能性

つまり、実行時エラーの温床。

やから -T になってるわけやな。

Flow の変性を押さえると React の型が急に読みやすくなる

ここまでで分かるように、React の型には

  • Element 系 → 共変+T
  • RefSetter反変-T
  • Mutable なもの → 不変

という「安全な変性設計」 が一貫して適用されてる。

Flow の変性記法を知ってると、

React の型がなぜこう設計されてるか」が一気に見えるようになる

んよな。やから、変性の概念を理解しておくと、React のコードが一気に読みやすくなるで〜

Flow の変性のまとめ

  • FlowFacebook 製の JavaScript 型チェッカー(React と親和性が高い)
  • Flow の特徴は 変性の明示(+T: 共変 / -T: 反変 / T: 不変
    • ReactElement<+C> は読み取り専用 → 共変+C
    • RefSetter<-T> は値を受け取るだけ → 反変-T
    • 一般的な Props<T> のように、読み書きの両方が起きる可能性がある型 → 不変T
  • Flow のおかげで React の型は一貫して安全に設計されていた

🛠️ 実務でどこに効いてくるの?

ここまでの話は一見「理論」っぽいけど、実務でもよく顔を出すで〜

  • onClick, onChange, onSubmit みたいな UI イベントハンドラ
  • onSuccess, onError みたいな API コールバック

これらは全部「引数を受け取る関数」なので、本来は 反変 で考えるべきもの。

たとえば公開 API の型を設計するときは、

  • コールバックの引数は できるだけ広い型 を受け取れるようにしておく
  • 逆に「特定のサブタイプしか想定してないハンドラ」を、汎用ハンドラの代わりに雑に代入しない

みたいな感覚があると、両変の罠を踏みにくくなるんちゃうかな。

readonly を付けるかどうか、Box<T> を公開するか ReadonlyBox<T> だけにするか、
こういう API 設計も、実は全部「変性の話」やったりするので、頭の片隅に置いておくとだいぶ設計が楽になるはずや!

📌 まとめ

ここまで見てきたように、変性そのものは日常で名前を意識することは少ないけど、

  • readonly にするかどうか
  • コールバックの引数の型をどこまで広くするか
  • Box<T> みたいなコンテナをそのまま公開するか、ReadonlyBox<T> だけにするか
  • React の型定義を読んだときに「なんでこう書いてあるのか」を納得できるかどうか

みたいなところで、じわじわ効いてくる概念やと思う。

「共変・反変・不変・両変」を、暗記するものというより「API 設計のときに思い出すチェックリスト」くらいに捉えてもらえるとちょうどええんかな〜と。

あと、今回の変性の型を検証できるリポジトリを作ったから、気になったら遊んでみてな!👇

https://github.com/nyaomaru/variance-check

ほな、ええ変性ライフを 🐈‍⬛✨

参考にしました

https://en.wikipedia.org/wiki/Type_variance

https://www.typescriptlang.org/docs/handbook/2/generics.html#variance-annotations

https://www.totaltypescript.com/method-shorthand-syntax-considered-harmful

https://www.sandromaglione.com/articles/covariant-contravariant-and-invariant-in-typescript

https://zenn.dev/jay_es/articles/2024-02-13-typescript-variance

https://typescriptbook.jp/reference/generics/variance

https://effectivetypescript.com/2021/05/06/unsoundness/

おまけの宣伝

TypeScript を書いていると、isXXXってちょこちょこ出てこうへん?
あれ毎回宣言するのめんどくさいし、結構手間。
せやから簡単に構築できる is-kit っちゅう OSS 作ってみたから、遊んでいってなぁ〜!🐈

is-kit logo

https://github.com/nyaomaru/is-kit

関連記事もチェックしてなぁ〜!😸

https://zenn.dev/nyaomaru/articles/introduce-is-kit

https://zenn.dev/nyaomaru/articles/design-type-guard

https://zenn.dev/nyaomaru/articles/escape-from-if-nest

Discussion