「型=集合」だけでは解けない謎。中学数学の直感を捨てて、TypeScriptの keyof never を理解する
はじめに
プログラミングで「型(Type)」を学ぶとき、私たちは無意識のうちに 中学や高校の数学で習った「集合」 のイメージで理解しようとします。
-
boolean型はtrueとfalseという2つの要素が入った集合。 -
string型は無限にある文字列の集合。 - そして
never型は、要素が一つもない「空集合( )」。\emptyset
このメンタルモデルは非常に優秀で、TypeScriptの学習の9割くらいはこの直感でうまくいきます。しかし、ふとした瞬間にこの直感が通用しない「不可解な挙動」に出くわすことがあります。
その代表例が、keyof never です。
type Result = keyof never;
// Result: string | number | symbol
集合論で考えれば、never は空集合です。「中身が何もない空っぽの箱」の鍵(key)なんて、当然「存在しない(never)」はずですよね?
しかし、TypeScriptが出した答えは真逆でした。string | number | symbol、つまり 「オブジェクトのキーになりうる全ての型」 が返ってきたのです。
「空集合のキーが、なぜ全体集合になるのか?」
このパラドックスは、「型を丸い円(ベン図)のような集合」として捉えている限り、納得のいく答えには辿り着けません。ここで必要になるのが、少しだけ視点を変えて 「矢印(関係性)」 で世界を見る考え方です。
今回は、集合論の直感で躓いてしまった人に向けて、 圏論(Category Theory) という数学の考え方を少しだけ借りて、このミステリーを解き明かしていきます。「圏論」と聞くと身構えてしまうかもしれませんが、難しい数式は使いません。「丸(集合)」を「矢印」に置き換えるだけで、この謎が驚くほどスッキリ理解できるようになります。
1. 直感の罠:型を「集合」として見てみる
まず、私たちが直感的にイメージしやすい「集合(ベン図)」のアプローチで考えてみましょう。
ここにおなじみの Animal と Dog があります。
interface Animal {
name: string; // 名前
age: number; // 年齢
}
interface Dog extends Animal {
breed: string; // 犬種(プードル・チワワなど)
}
これを集合として捉えると、Dog は Animal の一種ですから、Dog 集合は Animal 集合の部分集合になります(Dog は Animal に包含されます)。
そして、never です。 never は値が存在しない型ですから、 どの型の「要素」にもなりません。 しかし同時に、空集合はあらゆる集合の部分集合であるため、 あらゆる型の「内側」に含まれる最も小さな集合 として位置づけられます。
【図1:型の集合(包含関係)】 ※ここでは説明を単純にするため、unknown / Animal / Dog / never だけを取り出して描いています。
直感的には、外側が広く、内側に行くほど狭くなります。
この図を見ると、次のように考えたくなります。
「
DogはAnimalの内側にある(小さい)。neverはもっと小さい(空っぽ)。
だから、neverのキーも一番小さく(空に)なるはずだ!」
これが、集合論的な直感の罠です。
2. 反転する世界:キーの集合を見てみる
では、実際に keyof をとって「キーの集合」を見てみましょう。
type AnimalKeys = keyof Animal; // "name" | "age"
type DogKeys = keyof Dog; // "name" | "age" | "breed"
お気づきでしょうか?
型の集合としては小さかった(内側にあった)Dog ですが、キーの集合としては Animal よりも大きくなっています。
-
Animalのキー:2個 -
Dogのキー:3個
これを図にすると、包含関係が完全に逆転していることがわかります。
【図2:キーの集合】
図1では内側にあった Dog 由来のものが、外側に来て、Animal を飲み込んでいます。
-
図1(型の集合):
Dog\subset Animal -
図2(キーの集合):
keyof Animal\subset keyof Dog
「具体的な型(狭い集合)になればなるほど、プロパティの定義は増えるため、キーの集合は広くなる」
これが直感と逆になる理由です。
3. 圏論の矢印で理解する
この「逆転」を正しく捉えるためには、視点を変える必要があります。「円の大きさ(ベン図)」を見るのをやめて、「矢印の向き(関係性)」 で世界を見てみるのです。
少し抽象的な話になりますが、ここが理解できると never の謎が一気に解けます。
Step 1: ベン図を「矢印」に翻訳して「世界」を作る
まず、私たちがこれまで見てきた「ベン図の包含関係」を、シンプルな「矢印」のルールに書き換えてみましょう。
ルールはたった一つです。
「
このルールを使って、2つの「世界(地図)」を描いてみます。
① 「型の世界」を描く
まず、図1(型のベン図) をこのルールで書き換えます。
Dog は Animal の内側にありました。つまり、矢印は Dog から Animal へ伸びます。
-
関係:
Dog\subset Animal -
矢印:
Dog \xrightarrow{extends} Animal
このように、型同士の包含関係をすべて矢印でつないだ地図、これをここでは 「型の世界」 と呼ぶことにします。
② 「キーの世界」を描く
次に、図2(キーのベン図) も同じルールで書き換えます。
ここでは Animal のキー(2個)が、Dog のキー(3個)の内側にありました。
包含関係が逆なので、当然、矢印の向きも逆になります。
-
関係:
keyof Animal\subset keyof Dog -
矢印:
keyof\ Animal \xrightarrow{extends} keyof\ Dog
こちらは、キーの集合同士の包含関係を矢印でつないだ地図、つまり 「キーの世界」 です。
Step 2: keyof は 2つの世界をつなぐ「橋」
ここで重要なのが、keyof という操作の正体です。
単にプロパティ名を取り出すだけではありません。keyof は、「型の世界」の住人を、「キーの世界」の住人へと移動させる「橋渡し」 の役割を果たしているのです。
この「橋」を渡ると、不思議なことが起こります。
先ほど見たように、「型の世界」での矢印の向きが、「キーの世界」に着いた途端にすべて逆向き(反転)になってしまうのです。
これを図で確認してみましょう。
【図3:keyof による世界の対応と反転】
下が「型の世界」、上が「キーの世界」です。真ん中の点線が keyof という橋渡しを表しています。
-
左側(型の世界): 下から上へ(Dog
Animal)\to -
右側(キーの世界): 上から下へ(keyof Animal
keyof Dog)\to - 真ん中の点線(keyof): 型の世界からキーの世界へ橋を渡ると、上下関係がひっくり返る。
Step 3: カメラを引いて「世界の果て」を見る
さて、Step 2 までは Dog と Animal という、世界のほんの一部(中腹)だけを見ていました。
ここで少しカメラを引いて、「型の世界」の全体像(ロングショット) を眺めてみましょう。
本来、この世界には string や boolean、Date、そしてあなたが定義するあらゆるインターフェースなど、無数の型が存在しています。Dog や Animal は、その広大なネットワークの中からたまたま拾い上げた2つの点に過ぎません。
しかし、どんなに型が増えても、すべての矢印が従う「大きな流れ」は変わりません。
この世界には、「絶対的な始点」 と 「絶対的な終点」 が存在します。
1. 型の世界の全体像
-
絶対的な終点:
unknown
すべての矢印が最終的に行き着く場所です。DogもAnimalも、その他のどんな型も、矢印をたどっていけば最後は必ずここに到達します。ここから先に出る矢印はありません。 -
絶対的な始点:
never
すべての矢印の始まりの場所です。ここには何も入ってきませんが、ここからはあらゆる型に向けて矢印が伸びていきます。
これを図にすると、Dog や Animal は、この壮大な川の流れに浮かぶ、単なる通過点であることが分かります。
【図4:型の世界の全体像】
※ Dog や Animal 以外にも、無数の型(...)がこの流れの中に存在しています。
型の世界では、すべての矢印は never(始点)から出発し、無数の型を経由しながら、最終的に unknown(終点)へと向かいます(もちろん
2. キーの世界へ移動
では、この全体像を保ったまま、keyof の橋を渡って 「キーの世界」 へ移動しましょう。
Step 2 のルールを思い出してください。「橋を渡ると、矢印の向きはすべて逆転」します。
すると、どうなるでしょうか?
世界の「中腹」にいた Dog や Animal の順序がひっくり返るだけでなく、「始点」と「終点」の役割が完全に入れ替わります。
-
unknownの逆転:
「絶対的な終点(入ってくる場所)」だったunknownは、矢印が逆流することで 「絶対的な始点(出ていく場所)」 に変わります。
(実際、keyof unknownはneverになります) -
neverの逆転:
ここが核心です。「絶対的な始点(出ていく場所)」だったneverは、すべての矢印が集まってくる 「絶対的な終点(行き着く場所)」 へと姿を変えます。
【図5:キーの世界での天地逆転】
ここで一つ、疑問が浮かぶかもしれません。
「型の世界の終点が
unknownなら、キーの世界の終点もunknownになるんじゃないの?」
しかし、ここには 「キーの世界の厳しいルール」 が関係しています。
JavaScript/TypeScript の仕様上、オブジェクトのキーとして使える値は string、number、symbol の3種類だけに限られています。boolean や object など、その他の型はこの世界に入ることができません。
つまり、キーの世界における「全体集合(宇宙)」は、unknown(なんでもあり)ではなく、「キーになりうる値のすべて」である string | number | symbol に制限されるのです。
ここまできて、ようやく謎の正体が掴めました。
型の世界で「始点(底)」にいた never は、世界が反転したことで、キーの世界の「終点(頂点)」へと持ち上げられました。そして、キーの世界の頂点とは、仕様上許される 「全てのキーの集合(string | number | symbol)」 のことに他なりません。
「もっとも狭い型(never)」のキーが、「もっとも広いキー(string | number | symbol)」になる。
それは直感に反するパラドックスではなく、「始点」が「終点」に入れ替わった結果として現れる、あまりにも自然な姿だったのです。
結論
keyof never が string | number | symbol になるのは、決してバグでも適当な仕様でもありません。
これまでの話をまとめると、以下のような論理的必然性が見えてきます。
-
反変性(矢印の反転):
keyofを通すと、世界の関係性は逆転します。具体的な型(狭い型)ほど、キーの情報量は増え(広い型になり)ます(例:DogのキーはAnimalより多い)。 -
構造上の必然(底から頂点へ):
この反転ルールに従えば、型の世界の「底」であるneverは、必然的にキーの世界の「頂点」(string | number | symbol)にならざるを得ません。
「集合の包含関係(ベン図)」という直感を捨て、「矢印の向き」という新しい地図を手に入れた今、この一見奇妙な挙動も、実は非常に美しい対称性を持った 「正しい答え」 であることが理解できたのではないでしょうか。
もし、この「矢印で考える面白さ」に少しでも心を動かされたのなら、ぜひ圏論の世界をもう少し覗いてみてください。そこには、普段書いているコードの景色を一変させるような美しい法則が、まだまだたくさん眠っています。
補足:少し厳密な話(圏論を知る人へ)
ここまでは直感的な理解を優先してきましたが、最後に少しだけ数学的に正確な補足をしておきます。
1. 恒等射の存在
本文中では説明を簡略化するために「never に入ってくる矢印はない」と述べましたが、圏論において対象(オブジェクト)であるためには、 自分自身への矢印(恒等射:Identity Morphism) が存在する必要があります。
当然、never や unknown も例外ではありません。
never は「始対象(Initial Object)」であるため、任意の対象
2. keyof は反変関手である
今回の解説で登場した「型の世界」と「キーの世界(部分圏)」の対応関係は、圏論では 反変関手(Contravariant Functor) として記述できます。
型の圏を keyof は以下のような関手
この関手 keyof)は、以下の2つの仕事を同時に行っています。
-
対象の変換: 型
を、そのキーの型T に写す。Key(T) -
射の反転: 型の包含関係
を、キーの包含関係A \to B に写す。Key(B) \to Key(A)
私たちが「直感と逆だ」と感じた現象の正体は、この関手が持つ「反変性(Contravariance)」そのものだったのです。
Discussion