🔀

TypeScript の変性(共変・反変)を 5 分で理解する

2024/02/13に公開
7

TypeScript の記事などを読んでいると、共変・反変という聞き慣れない言葉に出くわすことがあります。一体何者なのでしょうか。この記事では、そんな共変・反変について、5 分で理解できるよう超ざっくりと解説します。

この後の記事内で使用する型と変数は以下のとおりです。

type User = {
  name: string
}

type Admin = User & {
  permissions: string[]
}

const user: User = {
  name: "user",
}

const admin: Admin = {
  name: "admin",
  permissions: [],
}

User は名前だけを持つ型で、Admin は名前と権限を持つ型です。また、それぞれの型の変数も用意しています。
この後のコード例は極力シンプルにするため、やや不自然なコードになっていますがご了承ください[1]

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

Admin 型の変数は name プロパティを持ち、User 型の条件を満たしています。そのため、User 型の変数には Admin 型の変数を代入できます。
その逆は型エラーになります。User 型の変数には permissions プロパティがなく、Admin 型を満たさないためです。

// admin は User の型を満たすので代入可能
const value1: User = admin

// 型エラー。Type 'User' is not assignable to type 'Admin'.
const value2: Admin = user

このように Admin 型は User 型としても扱うことができ、この関係性を「Admin 型は User 型のサブタイプである」といいます。
その反対に、「User 型は Admin 型のスーパータイプである」といいます。

関数の戻り値(共変: covariant)

次に、関数を代入する場合について見ていきます。型で指定した戻り値とは異なる値を返す関数を代入してみます。

// admin は User の型を満たすので代入可能
const fn1: () => User = () => admin

// 型エラー。Type 'User' is not assignable to type 'Admin'.
const fn2: () => Admin = () => user

User 型を返す関数」という型を指定した変数 fn1 に「Admin 型を返す関数」を代入できています。前に見たように Admin 型は User 型として扱えるので、これは理解しやすいでしょう。
一方、fn2 のように逆のパターンで代入しようとするとプロパティが足りないので型エラーになります。

このように、サブタイプの関係が必要であることを共変(もしくは共変の位置にある)と言います。

関数の引数(反変: contravariant)

続いて関数の引数について見てみましょう。型で指定した引数とは異なる型を受け取る関数を代入してみます。

// 型エラー。Type '(arg: Admin) => void' is not assignable to type '(arg: User) => void'.
const fn3: (arg: User) => void = (arg: Admin) => {};

// こっちはOK
const fn4: (arg: Admin) => void = (arg: User) => {};

User を受け取る関数」という型を指定した変数 fn3 に「Admin を受け取る関数」を代入しようとすると型エラーになり、その逆の fn4 は OK です。
先ほどと反対なので、これだけ見ると「おや?」となりますね(ならないですか? 筆者は最初なりました)。

関数の本体を以下のように変えてみるとなぜ型エラーになるのか分かりやすいです。

// 型エラー。型の引数は User だが、実際には Admin が必要
const fn3: (arg: User) => void = (arg: Admin) => console.log(arg.name, arg.permissions);

fn3(user); // "user" と undefined が出力される

// Admin を渡されても User の部分しか使わないので代入可能
const fn4: (arg: Admin) => void = (arg: User) => console.log(arg.name);

上の fn3 の例だと、型の上では「User を受け取る関数」となっていますが、実際には Admin 型が渡されることを想定しています。そのため name は出力されますが、permissionsUser 型の変数には存在しないので undefined になってしまいます。
この例では「undefined が出力される」程度の不具合ですが、コードを arg.permissions.length に変更すると以下のランタイムエラーが発生します。

Cannot read properties of undefined (reading 'length')

こういうことを防ぐため、「関数の型の引数 vs 実際の関数の引数」がサブタイプの関係になる場合は型エラーになるわけですね。
逆に fn4 は、型の上では「Admin を受け取る関数」で、実際には「User を受け取る関数」になっています。Admin 型は User 型として扱えるので、この代入は問題ありません。

関数の引数については、戻り値とは反対の関係になることが分かりました。
共変とは逆に、スーパータイプの関係が必要であることを反変(もしくは反変の位置にある)と言います。

おまけ: メソッド記法(双変: bivariant)の危険性

オブジェクトの型定義をする際にメソッド記法を使うと双変になります[2]。これは共変か反変のどちらかを満たしていればよい、という関係性です。そのため引数がサブタイプの場合でも型エラーになってくれません。つまり上記の反変のところで説明したようなランタイムエラーが実際に起ってしまう危険性があります。特別な意図がなければ避けるようにしましょう[3]

type MyObject = {
  fnProp: (arg: User) => void; // 関数プロパティによる定義
  method(arg: User): void; // メソッド記法による定義
}

const obj: MyObject = {
  // プロパティとして型定義された関数は反変なのでサブタイプは型エラー
  // Type '(arg: Admin) => void' is not assignable to type '(arg: User) => void'.
  fnProp: (arg: Admin) => { },

  // メソッド記法で型定義された関数は双変なのでサブタイプでも型エラーにならない!
  // 下記のコードはランタイムエラーを引き起こす
  method: (arg: Admin) => arg.permissions.length,
}

まとめ

この記事では、TypeScript における変性(共変・反変)について解説しました。具体的には、サブタイプとスーパータイプの関係、関数の戻り値における共変性、関数の引数における反変性について述べました。
自分の理解が足りておらず厳密な言葉遣いになっていない箇所があるかも知れません。改善点があったらコメントいただけると助かります!🙏

脚注
  1. 他の解説記事だと大体ジェネリクスを使って説明していますが、それだと前提となるコードが増えて認知負荷がやや高くなってしまうと感じたので、型を直接インラインで記述するようにしてみました。 ↩︎

  2. 影響するのは型定義時のみで、関数の実装の書き方は関係ありません。 ↩︎

  3. 詳細はこちらの記事や、プロを目指す人のためのTypeScript入門(通称ブルーベリー本)のコラム 17 を参照ください。 ↩︎

Discussion

zaruzaru

非常にわかりやすかったです。ありがとうございます。
もしかして、最初に書かれている「User 型は Admin 型のスーパータイプである」のクラス図の矢印は逆だったりしますか?(間違ってたら申し訳ないです)

jay-esjay-es

ありがとうございます!
思いっきり逆になってましたね...🙇
図を修正し、さらに説明を少し足してみました🙏

未確認生物未確認生物

反変の説明が非常に分かりやすかったです.
ありがとうございました.

kage1020kage1020

共変・反変という単語を初めて聞きましたが,型の代入には十分条件のみ受け付けるという視点はわかりやすくて面白かったです.
反変については,関数の外側から見ると確かに反対ですが,内側から見るとAdmin型のargUser型のオブジェクトが代入されようとしてエラーが発生していると順当に考えられますかね.

あと

逆に fn2 は、型の上では「Admin を受け取る関数」で、実際には「User を受け取る関数」になっています。Admin 型は User 型として扱えるので、この代入は問題ありません。

fn4の誤字ですかね?

jay-esjay-es

ありがとうございます!
ご指摘の通りの間違いでしたので修正しました 🙏