lamechang-dev

Webフロントエンドエンジニア lamechangのブログ。

【TypeScript】サブタイプとスーパータイプ、そして変性について

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

TypeScript上での型の間の関係についての概念である サブタイプ スーパータイプ についてです。

サブタイプ(subtype)

A、Bという2つの型があった場合に、BがAの派生型である場合、Aが要求されるところではどこでも、Bを利用することができる。

  • 配列はオブジェクトのサブタイプ
  • タプルは配列のサブタイプ
  • 数値 1 は numberのサブタイプ

スーパータイプ(supertype)

サブタイプの逆です。

A、Bという2つの型があり、BがAの上位型である場合、Bが要求されるところではどこでも、Aを安全に使うことができる。

  • オブジェクトは配列のスーパータイプ
  • 配列はタプルのスーパータイプ
  • number は 数値 1のスーパータイプ

変性とは?

ほとんどの基本型やシンプルな型(number, string, boolean...)については、なんらかの型Aが別の型Bのサブタイプであるかどうかを判別することは簡単ですね。例えば、 number | string という型があった場合にこれが string のスーパータイプであることは明らかです。

次に以下のようなオブジェクトの例を見てみます。以下のような例を考えます。

type LegacySong = {
  id?: number | string;
  name: string;
};

function deleteSongId(song: { id?: number; name: string }) {
  delete song.id;
}
const legacySong: LegacySong = {
  id: 123456,
  name: "old song",
};
//  Type 'string | number | undefined' is not assignable to type 'number | undefined'.
//  Type 'string' is not assignable to type 'number | undefined'.
deleteSongId(legacySong);

deleteSongId()が引数として期待しているオブジェクトの型と実際に渡されているとの間に問題が生じていることがわかります。

具体的に言うと、deleteSongId() は引数として期待するオブジェクトのプロパティ idnumber | undefinedであることを期待していますが、実際に呼び出し部分で渡されているlegacySongのプロパティ id は 'string | number | undefined'になっています。

TypeScriptはこのように、関数の引数やあらゆるシチュエーションにおいてオブジェクトが渡されることが期待される場合、期待される型のスーパータイプであるプロパティの型を持つ形状は渡すことができません。そしてこの性質を共変と呼びます。

そして、共変は、4種類の変性(variance)のうちの1つにすぎません。以下にその4種類の変性をまとめます。

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

関数の変性

関数Aが関数Bのサブタイプであるためには、以下を満たす必要があります。

  • 関数Aが関数Bと同じかそれより低いパラメーターの数を持つ。
  • 関数Aのthis型が指定されていない、または「Aのthis型がBのthis型のスーパータイプ」である。
  • 対応するそれぞれのパラメーターについて「Aのパラメーターの型 が Bのパラメーターのスーパータイプ」である。
  • 「Aの戻り値の型 が Bの戻り値のサブタイプ」である。

それぞれの構成要素(this型、パラメーターの型、戻り値の型)について、単にサブタイプでなのかな・・・と思いきやそれに準ずる要素は戻り値の型だけで、要素によって性質が反転する必要がありますね。なぜこのようなことになるのでしょうか?

以下の例で考えてみましょう。まず検証を行うために、3つのクラスを用意します。

class Pokemon {}

//DenkiPokemonはPokemonのサブタイプ
class DenkiPokemon extends Pokemon {
  electricShock() {
    console.log("電気ショック!");
  }
}

//PikachuはDenkiPokemonのサブタイプ
class Pikachu extends DenkiPokemon {
  lightning() {
    console.log("雷!");
  }
}

次に、以下ような1つの関数を引数として期待するコールバック clone()を定義します。

cloneに対してdenkiToDenkiを渡した時にエラーが起こらないことは明らかですね。じゃあ、denkiToPokemonはどうなんだと言うと、これはエラーを起こしてしまいます!😿

const clone = (f: (b: DenkiPokemon) => DenkiPokemon): void => {
  const parent = new DenkiPokemon();
  const babyDenkiPokemon = f(parent);
  babyDenkiPokemon.electricShock();
};

const denkiToDenki = (b: DenkiPokemon): DenkiPokemon => {
  return b;
};

const denkiToPokemon = (d: DenkiPokemon): Pokemon => {
  return d;
};

clone(denkiToDenki); // OK
clone(denkiToPokemon); // Argument of type '(d: DenkiPokemon) => Pokemon' is not assignable to parameter of type '(b: DenkiPokemon) => DenkiPokemon'.

clone()の処理を確認してみましょう。仮に渡す関数fがPokemonを返すとすると、処理の中で呼んでいる電気ショックをクラスPokemonは覚えていないので、実行することができないことがわかると思います。こういった理由から、TypeScriptは、渡された関数が「少なくとも」DenkiPokemonないしそのサブタイプを返すことを、コンパイル時に確認する必要があるのです。

一方で、パラメータについてはどうでしょうか。同じように確認してみます。

const clone = (f: (b: DenkiPokemon) => DenkiPokemon): void => {
  const parent = new DenkiPokemon();
  const babyDenkiPokemon = f(parent);
  babyDenkiPokemon.electricShock();
};

const pokemonToDenki = (b: Pokemon): DenkiPokemon => {
  return new DenkiPokemon();
};

const pikachuToDenki = (d: Pikachu): DenkiPokemon => {
  d.lightning();
  return new DenkiPokemon();
};

clone(pokemonToDenki); // OK
clone(pikachuToDenki); // Argument of type '(d: Pikachu) => DenkiPokemon' is not assignable to parameter of type '(b: DenkiPokemon) => DenkiPokemon'.

こちらも先程のケースと同様の考え方になります。 仮に関数のパラメータにDenkiPokemonのサブタイプであるPikachuを渡してしまうと、実際に関数がclone()の中で呼ばれる際にクラスDenkiPokemonlightning()を覚えていないのでエラーを吐くことになります。なので、TypeScriptは、渡された関数のパラメータが「少なくとも」DenkiPokemonないしそのスーパータイプを返すことを、コンパイル時に確認する必要があるのです。

これは、関数がそのパラメーターおよびthisの型に関して反変(contravariant)であることを意味します。つまり、ある関数が別の関数のサブタイプであるためには、それぞれのパラメーターおよびthisの型が、もう一方の関数で対応するものに対してスーパータイプである必要があります。

まぁ実際の現場の開発ではこのルールを覚えて実装する、なんて必要はないと思いますが、この制約が生まれる背景はしっかり抑えとくといいのかなと思いました。

※ちなみにTypeScript の関数は、それらのパラメーターおよび this の型に関して、デフォルトで共変らしいです。より安全な反変な振る舞い を選択するには、tsconfig.jsonの中で、{"strictFunctionTypes": true}フラグを有効にしてあげればOKです。

参考文献

www.oreilly.co.jp