部分型関係から考えるTypeScriptの`keyof`と`never`と`unknown`
はじめに
TypeScriptはkeyof
という型演算子を備えています。名前からも類推できるように、keyof
は直感的にはオブジェクトの型のキーを取り出す演算子です。例えば、以下のように使います。
type T = { a: number, b: string }
type Key = keyof T // "a" | "b"
型T
は"a"
と"b"
というキーを持つ型なので、keyof T
は"a" | "b"
型になるというわけです。
では、keyof never
やkeyof unknown
はどうなるでしょうか?
type KeyOfNever = keyof never // ???
type KeyOfUnknown = keyof unknown // ???
後に見るように、どんな型T
をとってきてもnever
はT
の部分型であることと、T
はunknown
の部分型であることがnever
とunknown
のそれぞれの特徴です。しかし、具体的にはどんな型なのでしょうか?そうした型のキーとは?ちょっと想像しにくいのではないでしょうか。
以下では、keyof never
とkeyof unknown
のいい感じの定義を決めるための考え方を紹介していきます。例えば、指数関数を例としてみましょう。正の実数keyof never
とkeyof unknown
に関しても、同様の説明が可能ということをこれから見ていきましょう。ただし、全体を通してany
は存在しないものとします。面倒なので。
本稿は次のような構成をとります。まず、部分型関係という概念について説明します。keyof
のいい感じの定義は、この部分型関係に関わるものです。次に、keyof
と少なからぬ関わりを持つ|
と&
というお馴染みの演算子を、部分型関係を使って定義します。そのあと、keyof
の定義域と値域について確認します。とりわけ値域は、keyof
と部分型関係の関係において重要な役割を果たします。そして、本丸のkeyof never
とkeyof unknown
の定義を確認し、「部分型関係を逆転させるという性質を維持する」という観点でその定義の正当化を試みます。最後に、|
、&
、そしてkeyof
と部分型関係の持つまた別の性質について触れます。
本稿の想定する読者は、TypeScriptの型システムをある程度使いこなせているプログラマーになります。各演算子や型の簡単な説明はありますが、見たことすらないという状態だと読み通すのは難しいかもしれません。
また、一つの謝辞として、Xのフォロワーからあるissueを紹介してもらったことが執筆のきっかけになったことを付言しておきます。
筆者自身、keyof
の挙動についてしっかり考えたことがなかったのですが、このやりとりをきっかけに理解が深まりました。感謝
部分型関係
部分型関係は型同士の関係[1]です。型A
が型B
の部分型(subtype)であるとき、A
に型付けられた項はB
としても型付けることが可能です。このとき、B
をA
の上位型(supertype)とも呼びます。
部分型関係は、TypeScriptの文脈では"assignability"という言葉で説明されることも多いです。例えば、以下のコードは型検査をパスします。
const a: { k1: number, k2: string } = { k1: 1, k2: 2 }
const b: { k1: number } = a // 割り当て(assign)可能
a
をb
に割り当てても安全なのは、b
の型はk1
キーがnumber
型であることだけを要求しているので、他にどんなキーを持っていようが関係がないからです。TypeScriptコンパイラはb
がk1
を持つことしか知らないので、他のキーへのアクセスを許してくれません。
b.k1 // ok
b.k2 // type error!
b.foo // type error!
また、型の条件部に現れるextends
も部分型関係を表します。すなわち、A extends B
はA
がB
の部分型であることを意味します。
type T = { k1: number, k2: string } extends { k1: number } ? true : false // true
{ k1: number, k2: string }
は{ k1: number }
の部分型なのでT
はtrue
となります。
さらに、どんな型T
に対してもT
はT
の、つまり自分自身の部分型です。型が同じだったら割り当てられるのは当然ですね。
|
と&
と部分型関係
|
と&
というお馴染みの型演算子は、部分型関係に関わるある性質を持っています。すなわち、A
とB
を型としたとき、A | B
はA
とB
の上位型であり、A & B
はA
とB
の部分型である、という二つが成り立ちます。
むしろ、本稿の趣旨からすれば、こう説明するのが適切でしょう。|
は与えた2つの型の上位型の中でも一番小さい型を返し、&
は、2つの型の部分型の中でも一番大きい型を返すのが定義であると。ここでの「小さい」と「大きい」は部分型関係に対応しています。A
がB
の部分型ならA
はB
よりも小さい型であり、B
はA
よりも大きい型です。
なんだか小難しい説明になってしまったので、具体例を示しましょう。
type U = string | number
let u: U = "hello"
u = 3
type Left = { a: string }
type Right = { b: string }
type I = Left & Right // { a: string, b: string }
declare const i: I
const left: Left = i
const right: Right = i
型U
はstring
かnumber
を要求するので、string
の値もnumber
の値も代入していいのは当然でしょう。そして、string
とnumber
の上位型はstring | number
だけではありません。string | number | Date
やstring | number | boolean
もそうですし、列挙しきれません。しかしそれらはどれも、string | number
の上位型なので、string | number
が一番小さいのです。
また、ここの型I
は{ a: string, b: string }
と等価です[2]。これに対しても|
と同様の説明が可能です。Left
とRight
の部分型は{ a: string, b: string }
だけではありません。{ a: string, b: string, c: string }
もそうです。しかし、一番大きい型が{ a: string, b: string }
なのはこれまでの説明から納得いただけるかと思います。
keyof
さて、前置きがすでに難しい気がしてましたが、本題のkeyof
です。
定義域と値域
keyof
はどんな型でも受け取れる型演算子なので、定義域はTypeScriptの型全体の集合になります。他方、値域はstring | number | symbol
[3]の部分型の集合になります。というのも、t
, key
を項としたとき、t[key]
という構文が許されるのはkey
の型がstring | number | symbol
の部分型のときだからです。つまり、TypeScriptにおいてはt[true]
などは不正な表現ということになります(なお、JavaScriptでは許されています)。
ここではとりわけ値域が重要です。なぜなら、keyof
は部分型関係を逆転させるという性質を持っており、それがkeyof never
とkeyof unknown
の定義に関わるからです。
部分型関係の逆転
「keyof
が部分型関係を逆転させる」とはすなわち、A
がB
の部分型であるとき、keyof B
はkeyof A
の部分型であることを意味します。例えば、{ a: number, b: string }
は{ a: number }
の部分型なので、keyof { a: number }
はkeyof { a: number, b: string }
の部分型となります。keyof { a: number }
は"a"
型で、keyof { a: number, b: string }
は"a" | "b"
型です。確かに逆転しています!
keyof never
とkeyof unknown
さて、核心に辿り着きました。keyof never
とkeyof unknown
はどうなるでしょうか?この「部分型関係を逆転させる」という法則を維持する定義にしたいのです。
本稿の冒頭で述べたように、どんな型T
に対してもnever
はT
の部分型です。つまり、それを逆転させるためには、どんなT
に対してもkeyof never
はkeyof T
の上位型となるように定めるべきです。keyof
の値域はstring | number | symbol
の部分型の集合だったことを思い出してください。そう、keyof never
はstring | number | symbol
となるのです!
では、keyof unknown
はどうでしょうか?unknown
はどんな型T
に対してもT
の上位型です。よって、keyof unknown
はkeyof T
の部分型となるわけです。すなわち、keyof unknown
はnever
となります。
type KeyOfNever = keyof never // string | number | symbol
type KeyOfUnknown = keyof unknown // never
|
と&
とkeyof
最後に、keyof
がもう一つの性質を持っていることに簡単に触れておきます。どんな型A
, B
に対しても以下が成り立ちます[4]。
keyof A | keyof B = keyof (A & B)
keyof never
とkeyof unknown
の定義はこの性質も維持します。A
、B
にnever
とunknown
を当てはめてみて、これが実際に成り立っていそうなことをお手元で確認してみてください[5]。
終わりに
以上、部分型関係を手がかりとしたkeyof
の解釈を試みました。読者の中には、「こういう性質を満たしたからといって何が嬉しいのか?」という疑問を持った方もいらっしゃるかもしれません。その疑問は全く正当だと思います。冒頭で紹介したissueにおいて、「最初はkeyof never
をnever
として定義したが多くの問題が発生した」というような旨が書かれていますが、それらが具体的にどんな問題なのかは調べられていませんし、筆者も具体例を述べることは正直のところできません。また、「こうした性質は純粋に数学的な関心事であり、現場のプログラマにとってはどうでもよい」という主張にも正面切って反論する材料を持ち合わせていません[6]。ただ、「演算子がある法則に沿うように定義されていると理解することで、挙動を予想しやすくなる」というメリットはあるのではないかと考えています。これは疑いなく実践的なメリットではないでしょうか。
-
ここで言う「関係」は数学的な意味での関係ですが、「親子関係」のような日常的な意味で理解してもさしあたり問題ないと思います。 ↩︎
-
厳密に言うとtscの内部では
{ a: string } & { b: string }
は{ a: string, b: string }
と異なる型として扱われますが、ここでは便宜上等価として扱います。playground ↩︎ -
このエイリアスとして、
PropertyKey
型がグローバルに利用できます。 ↩︎ -
これはTypeScriptの式ではなくメタ的な表現ですので注意してください ↩︎
-
一般的な法則が成り立っていることをコードから確認する術はありません。我々にできるのは、個々の具体例に対して成立していることを確認することだけです。 ↩︎
-
Array.prototype.every
の空配列に対する挙動について話題になったことが思い出されます https://zenn.dev/ncdc/articles/e110ec7d92a8a1 ↩︎
Discussion
とても勉強になりました
こちらは以下のtypoですかねkeyof A | keyof B = keyof (A & B)
いや、nerverとunknownにしか当てはまらなかったので違いますかね
あ、typoでしたので修正しました。ご指摘ありがとうございます!
コメント部分に
a: string
を書き忘れられていたりするでしょうか?🤔ご指摘ありがとうございます!修正しました
TypeScriptの型について理解が深まる素晴らしい記事でした。ありがとうございます。
知識不足で大変恐縮ですが、|と&と部分型関係の部分について一つ質問させてください
type a = unknown | never
だとaはunknown
型、type b = unknown & nerver
だと bはnever
型に決定されるかと思います(TS Playground)この動作は、
を踏まえた上でどう解釈すれば良いでしょうか?
ご質問ありがとうございます!
本文に引き続き、
any
は存在しないものとします。never
とunknown
両方の上位型はunknown
しかありません。本文中にも書いたように、どんな型も自分自身の部分型となります。それはつまり自分自身の上位型にもなることを意味するので、unknown
はunknown
の上位型です。また、unknown
の上位型はunknown
しかありません。そして当然、unknown
はnever
の上位型です。さらにいうと、どんな型
T
についてもunknown | T
はunknown
になります(上の段落中のnever
を他の型に置き換えても成り立ちます)&
についても、上の説明の|
を&
に変換し、never
とunknown
、上位型と部分型を相互に入れ替えるだけで説明できます(リテラルに入れ替えるだけなので試してみて下さい!)補足記事を書いたのでこちらもご参考までに