サブタイプとスーパータイプ
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",
};
deleteSongId(legacySong);
deleteSongId()
が引数として期待しているオブジェクトの型と実際に渡されているとの間に問題が生じていることがわかります。
具体的に言うと、deleteSongId()
は引数として期待するオブジェクトのプロパティ id
が number | undefined
であることを期待していますが、実際に呼び出し部分で渡されているlegacySong
のプロパティ id
は 'string | number | undefined'になっています。
TypeScriptはこのように、関数の引数やあらゆるシチュエーションにおいてオブジェクトが渡されることが期待される場合、期待される型のスーパータイプであるプロパティの型を持つ形状は渡すことができません。そしてこの性質を共変と呼びます。
そして、共変は、4種類の変性(variance)のうちの1つにすぎません。以下にその4種類の変性をまとめます。
- 不変性(invariance)
- 共変性(covariance)
- 反変性(contravariance)
- 双変性(bivariance)
- Tそのもの、もしくはスーパータイプ、サブタイプが必要
関数の変性
関数Aが関数Bのサブタイプ
であるためには、以下を満たす必要があります。
- 関数Aが関数Bと同じかそれより低いパラメーターの数を持つ。
- 関数Aのthis型が指定されていない、または「Aのthis型がBのthis型のスーパータイプ」である。
- 対応するそれぞれのパラメーターについて「Aのパラメーターの型 が Bのパラメーターのスーパータイプ」である。
- 「Aの戻り値の型 が Bの戻り値のサブタイプ」である。
それぞれの構成要素(this型、パラメーターの型、戻り値の型)について、単にサブタイプでなのかな・・・と思いきやそれに準ずる要素は戻り値の型だけで、要素によって性質が反転する必要がありますね。なぜこのようなことになるのでしょうか?
以下の例で考えてみましょう。まず検証を行うために、3つのクラスを用意します。
class Pokemon {}
class DenkiPokemon extends Pokemon {
electricShock() {
console.log("電気ショック!");
}
}
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);
clone(denkiToPokemon);
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);
clone(pikachuToDenki);
こちらも先程のケースと同様の考え方になります。
仮に関数のパラメータにDenkiPokemon
のサブタイプであるPikachu
を渡してしまうと、実際に関数がclone()
の中で呼ばれる際にクラスDenkiPokemon
は lightning()
を覚えていないのでエラーを吐くことになります。なので、TypeScriptは、渡された関数のパラメータが「少なくとも」DenkiPokemonないしそのスーパータイプを返すことを、コンパイル時に確認する必要があるのです。
これは、関数がそのパラメーターおよびthisの型に関して反変(contravariant)であることを意味します。つまり、ある関数が別の関数のサブタイプであるためには、それぞれのパラメーターおよびthisの型が、もう一方の関数で対応するものに対してスーパータイプである必要があります。
まぁ実際の現場の開発ではこのルールを覚えて実装する、なんて必要はないと思いますが、この制約が生まれる背景はしっかり抑えとくといいのかなと思いました。
※ちなみにTypeScript の関数は、それらのパラメーターおよび this の型に関して、デフォルトで共変らしいです。より安全な反変な振る舞い を選択するには、tsconfig.jsonの中で、{"strictFunctionTypes": true}フラグを有効にしてあげればOKです。
参考文献
www.oreilly.co.jp