プログラミングの型システムに関する記事を読んでいると、共変や反変といった用語が出てくることがある。
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