プログラミングの型システムに関する記事を読んでいると、共変や反変といった用語が出てくることがある。
TypeScript や Flow についての記事でも、見かけることがある。
それらは TypeScript を使う上で必須の知識ではないが、把握しておくに越したことはない。
この記事では、TypeScript を題材にして、変性について説明していく。
TypeScript に関する議論を理解できるようになることがこの記事の目的であり、より詳細な、学術的、数学的な内容には踏み込まない。
この記事の内容は、TypeScript のv3.9.5で動作確認している。
変性
変性(variance)とは、任意の型Tに対してどのような性質を持つのか示したものであり、以下の 4 種類がある。
不変性(invariance) |
Tそのものが必要 |
共変性(covariance) |
Tそのものか、そのサブタイプが必要 |
反変性(contravariance) |
Tそのものか、そのスーパータイプが必要 |
双変性(bivariance) |
Tそのものか、そのスーパータイプ、もしくはサブタイプが必要 |
これを見れば分かるように、「スーパータイプ」や「サブタイプ」といった概念と強く関連している。
そのためまず、「スーパータイプ」や「サブタイプ」といった用語について、整理しておく。
そのあとに、共変や反変の具体例を見ていく。
スーパータイプとサブタイプ
型のことを、値の集合だと考えることができる。
例えばnumberは、全ての数値の集合である。
numberという集合には、1や32などのあらゆる数値が含まれる。

このとき、numberの要素である1や32を、numberのサブタイプと呼ぶ。
逆に、numberのことを、1や32のスーパータイプと呼ぶ。
number | stringはnumberとstringの和集合であり、stringとnumberのスーパータイプということになる。

型 T に対しては T のサブタイプも割り当てることができる
Tという型が求められたとき、Tだけでなく、Tのサブタイプも割り当てることができる。
そのため、number型に対して1を割り当てることができる。
let x = 1; // let x: number const y = 1; // const y: 1 const a: number = x; // ok const b: number = y; // ok
逆に、Tのスーパータイプを割り当てることはできない。
以下のコードでは、1という型を持つaに、1のスーパータイプであるnumberを割り当てようとしているため、エラーになる。
let x = 1; // let x: number const y = 1; // const y: 1 const a: 1 = x; // Type 'number' is not assignable to type '1'.ts(2322) const b: 1 = y; // ok
number | stringはnumberやstringのスーパータイプなので、numberもstringも割り当てることができる。
type Foo = number | string; let x = 1; // let x: number const y = 1; // const y: 1 let z = "abc"; // let z: string const a: Foo = x; // ok const b: Foo = y; // ok const c: Foo = z; // ok
オブジェクトや配列は共変
先程の例はプリミティブ型だが、オブジェクト型ではどうなるか。
オブジェクトの場合、そのプロパティが、期待される型、もしくはそのサブタイプであるときに、割り当てることができる。
それ以外の型を割り当てようとした場合、エラーになる。
type Foo = { x: number; y: 1; }; // ok const a: Foo = { x: 1, y: 1, }; const b: Foo = { x: "1", // Type 'string' is not assignable to type 'number'.ts(2322) y: 1, }; let y = 1; // let y: number const c: Foo = { x: 1, y: y, // Type 'number' is not assignable to type '1'.ts(2322) };
この特徴はまさに、共変性である。
そのため、「オブジェクトは、そのプロパティについて共変である」と表現できる。
冒頭の表を再掲しておく。
不変性(invariance) |
Tそのものが必要 |
共変性(covariance) |
Tそのものか、そのサブタイプが必要 |
反変性(contravariance) |
Tそのものか、そのスーパータイプが必要 |
双変性(bivariance) |
Tそのものか、そのスーパータイプ、もしくはサブタイプが必要 |
配列も、共変である。つまり、T[]に対しては、T[]だけでなく、Tのサブタイプ[]も割り当て可能である。
type Foo = (string | number)[]; const x = [1]; // const x: number[] const y = ["1"]; // const y: string[] const z = [true]; // const z: boolean[] const a: Foo = x; // ok const b: Foo = y; // ok const c: Foo = z; // Type 'boolean' is not assignable to type 'string | number'.ts(2322)
関数の返り値は共変
最後に、関数の型について見ていく。
まず、検証用のクラスを 3 つ作成する。
class A { methodA() { console.log("A"); } } class B extends A { methodB() { console.log("B"); } } class C extends B { methodC() { console.log("C"); } }
TypeScript でクラスを定義すると同名の型が作られるが、この型は、そのクラスのインスタンスを意味する。
class A { methodA() { console.log("A"); } } const x = new A(); // const x: A
そして、継承関係に基づき、AがBとCのスーパータイプになり、BがCのスーパータイプとなる。
配列の復習も兼ねて、確認してみる。
class A { methodA() { console.log("A"); } } class B extends A { methodB() { console.log("B"); } } class C extends B { methodC() { console.log("C"); } } type ArrayOfA = A[]; type ArrayOfB = B[]; const foo: ArrayOfA = [new A(), new B(), new C()]; // ok const baz: ArrayOfB = [new B(), new C()]; // ok const quz: ArrayOfB = [new A()]; // Property 'methodB' is missing in type 'A' but required in type 'B'.ts(2741)
CはBのサブタイプなのでArrayOfBの要素になれるが、AはBのスーパータイプなので、ArrayOfBの要素にしようとするとエラーになる。
準備が整ったので、関数の型について検証していく。
まずは返り値から。
ReturnBはBを返す関数だが、Cを返す関数を割り当てることもできる。だが、Aを返す関数は、割り当てることができない。
CはBのサブタイプなので、「関数は、その返り値に関して共変である」ということができる。
class A { methodA() { console.log("A"); } } class B extends A { methodB() { console.log("B"); } } class C extends B { methodC() { console.log("C"); } } type ReturnB = () => B; const foo: ReturnB = () => new A(); // Property 'methodB' is missing in type 'A' but required in type 'B'.ts(2741) const bar: ReturnB = () => new B(); // ok const baz: ReturnB = () => new C(); // ok
関数のパラメータについて
次に関数のパラメータについて検証していくが、実はこれは、tsconfig.jsonのstrictFunctionTypesの設定によって変化する。
まずは、strictFunctionTypesがtrueのときの挙動を見ていく。
ちなみに、strictFunctionTypesはstrictに含まれているので、strictフラグを有効にすると自動的に有効になる。
TakeBはBを受け取る関数だが、Aを受け取る関数も割り当てることができる。だが、Cを受け取る関数は割り当てられない。
つまり、「関数は、そのパラメータに関して反変である」といえる。
class A { methodA() { console.log("A"); } } class B extends A { methodB() { console.log("B"); } } class C extends B { methodC() { console.log("C"); } } type TakeB = (arg: B) => void; const foo: TakeB = (arg: A) => {}; // ok const bar: TakeB = (arg: B) => {}; // ok const baz: TakeB = (arg: C) => {}; // Property 'methodC' is missing in type 'B' but required in type 'C'.ts(2322)
次に、strictFunctionTypesをfalseにして同じコードを実行してみる。
すると、どのパターンもエラーを出さなくなる。
const foo: TakeB = (arg: A) => {}; // ok const bar: TakeB = (arg: B) => {}; // ok const baz: TakeB = (arg: C) => {}; // ok
Bに対して、そのスーパータイプもサブタイプも割り当てることができるので、双変である。
つまり、以下のようになる。
strictFunctionTypes |
関数パラメータの変性 |
|---|---|
true |
共変 |
false |
双変 |
strictFunctionTypesをfalseにした場合、以下のコードを書いても TypeScript はエラーを出さない。
funcはTakeBを受け取るが、双変なので、パラメータがCであるcallMethodCも受け入れてしまう。
そして、funcのなかでBを渡しているが、BはmethodCを持っていないので、プログラムの実行時にエラーになってしまう。
class A { methodA() { console.log("A"); } } class B extends A { methodB() { console.log("B"); } } class C extends B { methodC() { console.log("C"); } } type TakeB = (arg: B) => void; const func = (arg: TakeB) => { const b = new B(); arg(b); // TypeError: c.methodC is not a function 実行時エラー }; const callMethodC = (c: C) => { c.methodC(); }; // (c: C) => void を受け入れてしまう func(callMethodC);
strictFunctionTypesをtrueにしてパラメータを反変にすることで、このエラーを事前に防ぐことができる。
反変なので、Cをパラメータとして受け取る関数をTakeBに割り当てることはできず、TypeScript がエラーを検知してくれる。
class A { methodA() { console.log("A"); } } class B extends A { methodB() { console.log("B"); } } class C extends B { methodC() { console.log("C"); } } type TakeB = (arg: B) => void; const func = (arg: TakeB) => { const b = new B(); arg(b); }; const callMethodC = (c: C) => { c.methodC(); }; func(callMethodC); // Property 'methodC' is missing in type 'B' but required in type 'C'.ts(2345)
ちなみに、反変なのでAをパラメータとして受け取る関数をTakeBに割り当てることができるが、これは問題にならない。
callMethodAにBを渡しても、BはAを継承しているためmethodAを実行できる。
class A { methodA() { console.log("A"); } } class B extends A { methodB() { console.log("B"); } } class C extends B { methodC() { console.log("C"); } } type TakeB = (arg: B) => void; const func = (arg: TakeB) => { const b = new B(); arg(b); }; const callMethodA = (a: A) => { a.methodA(); }; func(callMethodA); // A