[K, U] extends [U, K] ← ナニコレ
タイトルは初見時の自分の気持ちでした。内容は結構あっさりしたもので、5分あれば読めると思います。
「あーなるほどね」となった方はわざわざ読む必要がない記事っぽいです。
型の互換性チェック
一言で言ってしまえばそういうことです。KとUが互いに置き換え可能かどうかを確認しています。
これがKとUのままだと分かりづらいのですが、適当な型に置き換えてみると分かりやすいです。
type Test1 = [1, 1] extends [1, 1] ? true : false; // true
type Test2 = [number, number] extends [number, number] ? true : false; // true
type Test3 = [string, string] extends [string, string] ? true : false; // true
type Test4 = [1, 2] extends [2, 1] ? true : false; // false
type Test5 = [number, string] extends [string, number] ? true : false; // false
type Test6 = [boolean, string] extends [string, boolean] ? true : false; // false
原理はわかったので、あとは「[K, U] extends [U, K]と書けば型の互換性をチェックできる」と思っておけばよさそうです。
で、なにが便利なの?
以前の<T, K extends keyof T>の記事と同じくこれだけ見ても何が便利かさっぱりなので、今回もtype-challengesのこの問題を解いてみます。
JavaScriptのArray.include関数を型システムに実装します。この型は、2 つの引数を受け取り、trueやfalseを出力しなければなりません。
例えば:
type isPillarMen = Includes<['Kars', 'Esidisi', 'Wamuu', 'Santana'], 'Dio'> // expected to be
false
この問題を解くために[K, U] extends [U, K]の知識が必要になります。
答えとしては、
type Includes<T extends readonly any[], U> =
T extends [infer K, ...infer Rest] ?
[K, U] extends [U, K] ?
Equal<U, K>
: Includes<Rest, U>
: false
と書くことになります。
解説
まずIncludes<T extends readonly any[], U>で、Includesの一つ目の型は読み取り専用で何かしらの配列を受け取るものとします。
次にT extends [infer K, ...infer Rest]で、配列の先頭要素と残りの要素で型を分けます。
これがなかなか面白いコードで、このように書くことで擬似的にループを表現することができるようになります。
どういうことかというと、
T extends [infer K, ...infer Rest] ?
[K, U] extends [U, K] ?
Equal<U, K>
: Includes<Rest, U>
: false
これにより、互換性がない場合はfalseとなり、互換性がありU, Kが同じ型であればtrueが返ります。
残ったIncludes<Rest, U>ですが、こうして再びRestを使って再起的にチェックをしていくというわけです。
Equal
余談ですが、しれっと出てきてるEqualはtype-challengesが用意してくれている型です。
これも中身を見てみると…
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false
のように定義されています。
「これなんかさっき見たくね?」という感じですが、これに近いですよね?
type Test1 = [1, 1] extends [1, 1] ? true : false; // true
このEqualは型関数 <T>() => T extends X ? 1 : 2 が、型関数 <T>() => T extends Y ? 1 : 2 に代入可能かどうかをチェックしています。もし X と Y が同じ型であれば、これらの型関数は同じ型を持つため、true になります。
おわりに
[K, U] extends [U, K]もそうですが、T extends [infer K, ...infer Rest]もユニークな表現ですよね。
まさにパズルというか、業務ロジック書くのとは違う部分の頭を使わされてる感じがします。
📢 Kobe.tsというTypeScriptコミュニティを主催しています
フロント・バックエンドに限らず、周辺知識も含めてTypeScriptの勉強会を主催しています。
毎朝オフラインでもくもくしたり、神戸を中心に関西でLTもしています。
盛り上がってる感を出していきたいので、良ければメンバーにだけでもなってください😣
Discussion
この部分ってどうして
のように最初からEqualでチェックしないんでしょうか?
わざわざ互換性からチェックする理由って何かご存知ですか?
不正確な点があるように思いましたのでコメントさせていただきます。
まず、
[K, U] extends [U, K]だからといって、「KとUが同じ型である」とは限りません(もしかしたらKanonさんもそのことは認識されていて、「互換性」という言葉を使っているのはそれを考慮してのことかもしれませんが)。もし、[K, U] extends [U, K]が「KとUが同じ型である」ことを意味するならば、type-challengesのIncludesは以下でいいはずです。しかし、これはテストケースをパスしません。なぜなら、TypeScriptは
readonlyのついていない型を、同じ形でreadonlyのついた型の部分型とみなすからです(これもおそらく厳密には不正確な表現で、本当にそうであるならEqualを実装することはできません。{a: 'A'} extends {readonly a: 'A'} ? true : falseがtrueと評価されることを述べています)。記事中ではEqualを併用してテストケースをパスするようにしていますが、実装としては正しくないため、次のようなテストケースがあればうまく行かなくなります。これは、最初の要素で
[U, K] extends [K, U]の「条件を満たす側」に入ってしまい、それ以上再帰しなくなるためです。この問題に関しては、
[U, K] extends [K, U]を使う必要はほぼなく、回答にEqualが使えるのであればkuromaさんの指摘通りとするのが良さそうです。
Equalの実装はやけに複雑ですが、readonlyなどをめぐる先人たちの苦闘のあとということですね……。