ひびのログ

日々ではないけどログを出力していくブログ

Union Types を過不足なく満たす配列かどうかチェックする関数

結論

type Head<T> = T extends [infer U, ...infer _] ? U : never
type Tail<T> = T extends [infer _, ...infer U] ? U : never

type NecessaryAndSufficientLoop<Union, Arr extends any[]> =
  Arr extends [] ?
    [Union] extends [never] ?
      []
    : never
  : [
      Head<Arr> extends Union ? Head<Arr> : never,
      ...NecessaryAndSufficientLoop<Exclude<Union, Head<Arr>>, Tail<Arr>>,
    ]

type NecessaryAndSufficient<Union, Arr extends any[]> =
  Arr extends NecessaryAndSufficientLoop<Union, Arr> ? Arr : never

const checkNecessaryAndSufficient =
  <Union>() =>
  <const Arr extends any[]>(ary: Arr) =>
    ary as NecessaryAndSufficient<Union, Arr>

// ---
// テスト
// ---
type Fruit = "apple" | "orange"

const fa = ["apple", "orange"] as const
const f = checkNecessaryAndSufficient<Fruit>()([...fa])

// OK(渡した配列のタプル型になる)
const fruits1 = checkNecessaryAndSufficient<Fruit>()(["apple", "orange"])
const fruits2 = checkNecessaryAndSufficient<Fruit>()(["orange", "apple"])

// NG(never になる)
const fruits3 = checkNecessaryAndSufficient<Fruit>()([])
const fruits4 = checkNecessaryAndSufficient<Fruit>()(["apple"])
const fruits5 = checkNecessaryAndSufficient<Fruit>()(["orange"])
const fruits6 = checkNecessaryAndSufficient<Fruit>()(["orange", "apple", "lemon"])
const fruits7 = checkNecessaryAndSufficient<Fruit>()(["orange", "apple", "orange"])

Playground で確かめたい人はこちらからどうぞ↓

TypeScript: TS Playground - An online editor for exploring TypeScript and JavaScript

なぜ作ったのか

この記事を見ていたら衝動的に。

qiita.com

ポイント解説

環境

TypeScript 5.4.2

Head, Tail

type Head<T> = T extends [infer U, ...infer _] ? U : never
type Tail<T> = T extends [infer _, ...infer U] ? U : never

みなさんのご家庭でもそこらへんに転がってる HeadTail。 配列の最初の要素を取得する Head と、配列の最初以外の要素を配列で返す Tail。 素材の味で勝負。

const checkNecessaryAndSufficient

関数が二重になっているのは、第二型引数を省略して書けるように。 複数の型引数のうち、一部だけ型推論させることはできないとのこと。

関数名は「必要十分条件」から。 用語として正しいかは怪しい……。 過不足ない、みたいな名前のほうが良かったかも?

const Type Parameters

<const Arr extends any[]>(ary: Arr)

TypeScript 5.0 で導入された const Type Parameters を使用。 引数へ渡す配列に as const をつけなくてもタプル型として型推論してくれるえらい子。

ただの配列型として型推論されてしまうので、先に変数宣言した場合は as const しないといけない。 そうすると、逆にこの関数へ渡せなくなる。 readonly になり、反変性がなくなるから。 その場合はスプレッド構文を使って配列を再生成すれば OK。

const asConsted = ["apple", "orange"] as const
const fruits = checkNecessaryAndSufficient<Fruit>()([...asConsted])

初めて使ったけど結構便利。

type NecessaryAndSufficientLoop<Union, Arr extends any[]>

メインのロジック。

配列の先頭から順番に見ていって、型に含まれてたらそれを除いた型と配列の残りで再帰する。

細かく見る

配列が 0 になるまで(Arr extends [] ?)回して、その時に Union Types を全部使い切っていたら([Union] extends [never] ?)、空配列を返す。 配列が 0 になっているのに Union Types を使い切っていなかったら never を返す。

配列の先頭が Union Types に含まれていたらその要素を、含まれていなかったら never を配列の先頭にセット(Head<Arr> extends Union ? Head<Arr> : never)。

Union Types の残りと配列の残りで再帰(...NecessaryAndSufficientLoop<Exclude<Union, Head<Arr>>, Tail<Arr>>)。

これで、["apple", "orange", never] のような配列が出来上がる。

空配列の判定

Arr extends [] で空配列かどうかを判定できるみたい。 ググるとだいたい Arr["length"] extends 0 とかで判定しているし、そうしないといけないと思っていた。

もしかしたら考慮できていないことがあるのかも?

never の判定

[Union] extends [never] と書く。 これは Union Distribution されないように。

ページが擦り切れるほど見たこちらの記事を参照。

qiita.com

type NecessaryAndSufficient<Union, Arr extends any[]>

最後の仕上げ。 配列に never が含まれているかを判定して、含まれていなければ元の配列の型を返す。

苦肉の策。 再帰の最中に大域脱出できればよかったんだけど。 処理前後で配列が一致しているかを調べている。

ちなみに

フォーマッターに Prettier を使っているが、引き続き curious ternaries を使用している。 型パズルは三項演算子を使いまくるので、慣れればこちらのほうが読みやすいと感じている。

sosukesuzuki.dev

感想

やっぱり型パズルは楽しい。 最近はゴリゴリにロジックを書くということが少なくなっていたから、頭の体操になったような気がする。

いつかは type-challenges にも逃げずに立ち向かわねば……。