⚡️

[K, U] extends [U, K] ← ナニコレ

2024/07/15に公開2

タイトルは初見時の自分の気持ちでした。内容は結構あっさりしたもので、5分あれば読めると思います。

「あーなるほどね」となった方はわざわざ読む必要がない記事っぽいです。

型の互換性チェック

一言で言ってしまえばそういうことです。KUが互いに置き換え可能かどうかを確認しています。

これがKUのままだと分かりづらいのですが、適当な型に置き換えてみると分かりやすいです。

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 に代入可能かどうかをチェックしています。もし XY が同じ型であれば、これらの型関数は同じ型を持つため、true になります。

おわりに

[K, U] extends [U, K]もそうですが、T extends [infer K, ...infer Rest]もユニークな表現ですよね。

まさにパズルというか、業務ロジック書くのとは違う部分の頭を使わされてる感じがします。

📢 Kobe.tsというTypeScriptコミュニティを主催しています

フロント・バックエンドに限らず、周辺知識も含めてTypeScriptの勉強会を主催しています。

毎朝オフラインでもくもくしたり、神戸を中心に関西でLTもしています。

盛り上がってる感を出していきたいので、良ければメンバーにだけでもなってください😣

https://kobets.connpass.com/

Discussion

kuromakuroma
  [K, U] extends [U, K] ?
    Equal<U, K>
    : Includes<Rest, U>
  : false

この部分ってどうして

  Equal<U, K> extends true ?
    true
    : Includes<Rest, U>
  : false

のように最初からEqualでチェックしないんでしょうか?
わざわざ互換性からチェックする理由って何かご存知ですか?

cisdurcisdur

不正確な点があるように思いましたのでコメントさせていただきます。

まず、[K, U] extends [U, K]だからといって、「KとUが同じ型である」とは限りません(もしかしたらKanonさんもそのことは認識されていて、「互換性」という言葉を使っているのはそれを考慮してのことかもしれませんが)。もし、[K, U] extends [U, K]が「KとUが同じ型である」ことを意味するならば、type-challengesのIncludesは以下でいいはずです。

type Includes<T extends readonly any[], U> =
T extends [infer K, ...infer Rest] ?
  [K, U] extends [U, K] ?
    true
    : Includes<Rest, U>
  : false

しかし、これはテストケースをパスしません。なぜなら、TypeScriptはreadonlyのついていない型を、同じ形でreadonlyのついた型の部分型とみなすからです(これもおそらく厳密には不正確な表現で、本当にそうであるならEqualを実装することはできません。{a: 'A'} extends {readonly a: 'A'} ? true : falsetrueと評価されることを述べています)。記事中ではEqualを併用してテストケースをパスするようにしていますが、実装としては正しくないため、次のようなテストケースがあればうまく行かなくなります。

// この行をcasesに追加してみてください
Expect<Equal<Includes<[{readonly a: 'A'}, {a: 'A'}], {a: 'A'}>, true>> // Includesがtrueを返すべきだが、falseになってしまう

これは、最初の要素で[U, K] extends [K, U]の「条件を満たす側」に入ってしまい、それ以上再帰しなくなるためです。

この問題に関しては、[U, K] extends [K, U]を使う必要はほぼなく、回答にEqualが使えるのであればkuromaさんの指摘通り

type Includes<T extends readonly any[], U> =
T extends [infer K, ...infer Rest] ?
  Equal<U, K> extends true ?
  true
  : Includes<Rest, U>
: false

とするのが良さそうです。

Equalの実装はやけに複雑ですが、readonlyなどをめぐる先人たちの苦闘のあとということですね……。