30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

TypeScript における変性(variance)について

プログラミングの型システムに関する記事を読んでいると、共変や反変といった用語が出てくることがある。
TypeScript や Flow についての記事でも、見かけることがある。
それらは TypeScript を使う上で必須の知識ではないが、把握しておくに越したことはない。
この記事では、TypeScript を題材にして、変性について説明していく。
TypeScript に関する議論を理解できるようになることがこの記事の目的であり、より詳細な、学術的、数学的な内容には踏み込まない。

この記事の内容は、TypeScript のv3.9.5で動作確認している。

変性

変性(variance)とは、任意の型Tに対してどのような性質を持つのか示したものであり、以下の 4 種類がある。

不変性(invariance Tそのものが必要
共変性(covariance Tそのものか、そのサブタイプが必要
反変性(contravariance Tそのものか、そのスーパータイプが必要
双変性(bivariance Tそのものか、そのスーパータイプ、もしくはサブタイプが必要

これを見れば分かるように、「スーパータイプ」や「サブタイプ」といった概念と強く関連している。
そのためまず、「スーパータイプ」や「サブタイプ」といった用語について、整理しておく。
そのあとに、共変や反変の具体例を見ていく。

スーパータイプとサブタイプ

型のことを、値の集合だと考えることができる。
例えばnumberは、全ての数値の集合である。
numberという集合には、132などのあらゆる数値が含まれる。

f:id:numb_86:20200704092431p:plain:w600

このとき、numberの要素である132を、numberのサブタイプと呼ぶ。
逆に、numberのことを、132のスーパータイプと呼ぶ。

number | stringnumberstringの和集合であり、stringnumberのスーパータイプということになる。

f:id:numb_86:20200704092450p:plain:w600

型 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 | stringnumberstringのスーパータイプなので、numberstringも割り当てることができる。

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

そして、継承関係に基づき、ABCのスーパータイプになり、BCのスーパータイプとなる。

配列の復習も兼ねて、確認してみる。

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)

CBのサブタイプなのでArrayOfBの要素になれるが、ABのスーパータイプなので、ArrayOfBの要素にしようとするとエラーになる。

準備が整ったので、関数の型について検証していく。

まずは返り値から。

ReturnBBを返す関数だが、Cを返す関数を割り当てることもできる。だが、Aを返す関数は、割り当てることができない。
CBのサブタイプなので、「関数は、その返り値に関して共変である」ということができる。

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.jsonstrictFunctionTypesの設定によって変化する。
まずは、strictFunctionTypestrueのときの挙動を見ていく。
ちなみに、strictFunctionTypesstrictに含まれているので、strictフラグを有効にすると自動的に有効になる。

TakeBBを受け取る関数だが、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)

次に、strictFunctionTypesfalseにして同じコードを実行してみる。
すると、どのパターンもエラーを出さなくなる。

const foo: TakeB = (arg: A) => {}; // ok
const bar: TakeB = (arg: B) => {}; // ok
const baz: TakeB = (arg: C) => {}; // ok

Bに対して、そのスーパータイプもサブタイプも割り当てることができるので、双変である。

つまり、以下のようになる。

strictFunctionTypes 関数パラメータの変性
true 共変
false 双変

strictFunctionTypesfalseにした場合、以下のコードを書いても TypeScript はエラーを出さない。
funcTakeBを受け取るが、双変なので、パラメータがCであるcallMethodCも受け入れてしまう。
そして、funcのなかでBを渡しているが、BmethodCを持っていないので、プログラムの実行時にエラーになってしまう。

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);

strictFunctionTypestrueにしてパラメータを反変にすることで、このエラーを事前に防ぐことができる。
反変なので、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に割り当てることができるが、これは問題にならない。
callMethodABを渡しても、BAを継承しているため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

参考資料