Open15

type-challengesで難しかったものをメモ

SAKATAROSAKATARO

First of Array

質問: 配列Tを受け取り、その最初のプロパティの型を返すFirst<T>を実装します

https://github.com/type-challenges/type-challenges/blob/main/questions/00014-easy-first/README.ja.md

誤答

誤答
type First<T extends any[]> = T[0]

テスト結果を見たところ、入力が[]のテストケース(Expect<Equal<First<[]>, never>>,)だけ、結果がNGになった。

正解

回答を見たところ、模範解答は3種類くらいあり、いずれも条件分岐を含み、[]の場合を特別扱いする必要があるみたい。

正解
// Answer 1
type First<T extends any[]> = T extends [] ? never : T[0]

// Answer 2
type First<T extends any[]> = T["length"] extends 0 ? never : T[0]

// Answer 3
type First<T extends any[]> = T extends [infer A, ...any] ? A : never
SAKATAROSAKATARO

Exclude

質問: 組み込みの型ユーティリティExclude <T, U>を使用せず、Uに割り当て可能な型をTから除外する型を実装します。

https://github.com/type-challenges/type-challenges/blob/main/questions/00043-easy-exclude/README.ja.md

正解

正解
type MyExclude<T, U> = T extends U ? never : T

仕組み

MyExclude<'a' | 'b' | 'c', 'a' | 'd'>'b' | 'c'になるのはどうして?

'a' | 'b' | 'c' extends 'a' | 'd' ? never : Tに展開されて、
'a' | 'b' | 'c',は'a' | 'd'には代入不可能だから、neverになるのでは?

……と思ったが、Distributive Conditional Types(分散Conditional Types)によると、型パラメータは次のように分散されて適用されるらしい。

| ('a' extends 'a' | 'd' ? never : 'a')
| ('b' extends 'a' | 'd' ? never : 'b')
| ('c' extends 'a' | 'd' ? never : 'c')
SAKATAROSAKATARO

Awaited

質問: Promise ライクな型が内包する型をどのように取得すればよいでしょうか。

Awaited

誤答

誤答
// Wrong : 1
// `Promise<Promise<string | number>>`などがダメ。
type MyAwaited<T> = T extends Promise<infer R> ? R : T;

// Wrong : 2
// `{ then: (onfulfilled: (arg: number) => any) => any }`がダメ。
type MyAwaited<T> = T extends Promise<infer R> ? MyAwaited<R> : T;

正解

正解
type MyAwaited<T> = T extends PromiseLike<infer R> ? MyAwaited<R> : T;

再帰を使うことと、PromiseLikeを使うことがポイントでした。
Promiseライクはthenメソッドを持っているinterface型っぽい。

SAKATAROSAKATARO

Includes(実質Equalの話)

質問: JavaScriptのArray.include関数を型システムに実装します。この型は、2 つの引数を受け取り、truefalseを出力しなければなりません。

正解

正解
type Includes<T extends readonly any[], U> =
  T extends [infer F, ...infer R] ? (
    If<Equal<F, U>, true, Includes<R, U>>
  ) : (
    false
  )

Equal

Equal<T, U>はtype-challengesのユーティリティ関数なのだが、TUと同じ型ならばtrue型を、違う方ならばfalse型を、リテラル型として返す。

Equalも自分で実装したほうがいいのかな?」と思って、挑戦したが、うまく行かず、調べてみると、闇が深かった。
https://zenn.dev/yumemi_inc/articles/ff981be751d26c

SAKATAROSAKATARO

Omit

質問: 組み込みの型ユーティリティOmit<T, K>を使用せず、TのプロパティからKを削除する型を実装します。

https://github.com/type-challenges/type-challenges/blob/main/questions/00003-medium-omit/README.ja.md

正解(組み込み型ユーティリティのPickとExcludeを使う)

正解(組み込み型ユーティリティのPickとExcludeを使う)
type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>

正解(組み込み型ユーティリティを使わない)

導き方としては、正解1に対して、PickExcludeの式を展開すればいいのだが、Excludeを単純に展開するとPが循環制約だと怒られる。

type MyOmit<T, K extends keyof T> = {
  [P in Exclude<keyof T, K>] : T[P]
}
// ↓ 展開
// error : Type parameter 'P' has a circular constraint.
type MyOmit<T, K extends keyof T> = {
  [P in keyof T extends K ? never : P] : T[P]
}

as Pを挿入するとOKになるようです。
私はあまり理解できていない。

正解(組み込み型ユーティリティを使わない)
type MyOmit<T, K extends keyof T> = {
  [P in keyof T as P extends K ? never : P] : T[P]
}

追記:このas PはKey Remappingですね。キーを上書きする機能。
https://qiita.com/ryokkkke/items/7b16c238377b3b57d77f

今回だと、キーはkeyof Tから取り出された一つ一つのPで、as P extends K ? never : Pで一塊になっていて、Kに代入可能ならばnever(つまりキーから取り除く)という意味。

SAKATAROSAKATARO

Deep Readonly

質問: オブジェクトのすべてのパラメーター(およびそのサブオブジェクトを再帰的に)を読み取り専用にするDeepReadonly<T>を実装します。この課題ではオブジェクトのみを扱っていると想定してください。配列、関数、クラスなどは考慮する必要はありません。しかし、可能な限り様々なケースをカバーすることで、自分自身に挑戦することができます。

https://github.com/type-challenges/type-challenges/blob/main/questions/00009-medium-deep-readonly/README.ja.md

誤答

誤答
type DeepReadonly<T> = {
  readonly [P in keyof T]: DeepReadonly<T[P]>
}

この誤答は、無条件に再帰していくという方式。
一見すると良さげだが、パラメータに関数型があると、{}に変換されてしまう。

DeepReadonly<{a: () => 22}> // {readonly a: {}}になる。

正解

取れるアプローチは様々なのだが、ここでは紹介するアプローチは2つ。

  1. 関数の時だけ特別扱い。
  2. {}になるとき、つまりキーがneverになるときだけ特別扱い。
1. 関数の時だけ特別扱い。
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends Function ? T[P] : DeepReadonly<T[P]>
}
2. {}になるとき、つまりキーがneverのときだけ特別扱い。
type DeepReadonly<T> = {
  readonly [P in keyof T]: keyof T[P] extends never ? T[P] : DeepReadonly<T[P]>
}

カバーできるケースに違いがあるかは未調査。
ちなみに、string"hoge"は問題なし。どちらの解答でも、DeepReadonly<string>stringに、DeepReadonly<"hoge">"hoge"にと、そのままになる。

SAKATAROSAKATARO

Chainable Options

質問: JavaScript では、チェイン可能なオプションがよく使われます。しかし、TypeScript に切り替えたとき、正しく型を付けることができますか?この課題では、オブジェクトでもクラスでも何でもいいので、 option(key, value)get() の 2 つの関数を提供する型を定義してください。option では、与えられたキーと値を使って現在の config の型を拡張できます。最終的な結果は get で取得することにしましょう。

https://github.com/type-challenges/type-challenges/blob/main/questions/00012-medium-chainable-options/README.ja.md

誤答

誤答
type Chainable<T = {}> = {
  option<K extends string, V>(key: K, value: V)
    : Chainable<Omit<T, K> & {[P in K]: V}>
  get(): T
}

上記の解答でも、正常系では意図通りに動くのだが、異常系で意図したところにエラーが出ない。
下記のテストコードの@ts-expect-errorのところでエラーが出てほしい。

テストコード
const result2 = a
  .option('name', 'another name')
  // @ts-expect-error
  .option('name', 'last name')
  .get()

正解

正解
type Chainable<T = {}> = {
  option<K extends string, V>(key: K extends keyof T ? never : K, value: V)
    : Chainable<Omit<T, K> & {[P in K]: V}>
  get(): T
}

対応策は簡単で、エラーが出てほしい場合に、neverが返るように条件を追加する。extends keyof T ? never : Kのところ。

SAKATAROSAKATARO

Promise.all

質問: Promise ライクなオブジェクトの配列を受け取る関数 PromiseAll に型を付けてください。戻り値は Promise<T> である必要があります。ここで、T は解決された結果の配列です。

https://github.com/type-challenges/type-challenges/blob/main/questions/00020-medium-promise-all/README.ja.md

正解

正解
type UnpackPromise<T> = T extends Promise<infer I> ? I : T

declare function PromiseAll<T extends unknown[]>(values: readonly [...T])
  : Promise<{
    [K in keyof T]: UnpackPromise<T[K]>
  }>

ポイント

ポイントは3点。

  1. [...T]
  2. 配列の各要素に適用するための{[K in keyof T]: UnpackPromise<T[K]>}
  3. UnpackPromise

[...T]

PromiseAllの実装のポイントは、実引数に配列を受けて、結果に配列の要素数や並びを維持しなければならないということ。次のテストケースを通過するのに必要である。

Input:PromiseAll([1, 2, Promise.resolve(3)])
Expect:Promise<[number, number, number]>

ナイーブにジェネリクス関数(ここではGenericFunctionとする)を実装すると、TypeScriptは、GenericFunction([1, "abc", 3])という実引数を受けて、型引数をGenericFunction<(number|string)[]>と推論してしまう(Widening)。こうなってしまうと、既に配列の要素数や並びは失われている。

そこで、配列の要素数や並びを維持したまま、型引数を受け取る方法が[...T]

declare function GenericFunction2<T extends unknown[]>(array: readonly [...T]): T
const r21 = GenericFunction2([1,"abc",3])
// ⇒ 推論:func<[number, string, number]>
const r22 = GenericFunction2([1,"abc",3] as const)
// ⇒推論:func<[1,"abc",3]>

配列の各要素に適用するための{[K in keyof T]: UnpackPromise<T[K]>}

最終的に、配列内のPromise<TypeHoge>型を、すべてPromiseを剥がしたTypeHoge型にしたい。

ひとつの型のPromiseを剥がすのは後述のUnpackPromiseにやってもらうとして、
ここでは配列内の型にすべて適用する方法を考える。

素朴に考えると、「配列の先頭から再帰的に・・・」となるのだが、ここでMapped Typesを使うと、エレガントに書ける。

Mapped Typesで配列の各要素の型に適用。
type MapObjectWrap<T extends unknown[]> = {
  [K in keyof T]: {v: T[K]}
}

type Hoge = MapObjectWrap<[1,2,3]>
// ⇒ 結果:[{v: 1}, {v: 2}, {v: 3}]

UnpackPromise

Promise<TypeHoge>型からPromiseを剥がす。
説明不要かもしれないが、Promise<T>に渡されているTを推論して取り出したいので、Tのところでinferを使えばよい。

UnpackPromise
type UnpackPromise<T> = T extends Promise<infer I> ? I : T

一文で書く方法(現時点では不明;;)

上記の正解では、UnpackPromiseを別出しにして2文で書いているのだが、これを1文で書く方法がわからないため……。

単純に展開すると、下記のテストケースが通らない。

Input: PromiseAll<Array<number | Promise<number>>>([1, 2, 3])
Expect: Promise<number[]>

2文で書くと、上手く行く理由は、number | Promise<number>がDistributive Conditional Typeで分散して適用されるから。

誤答
[declare function PromiseAll<T extends unknown[]>(values: readonly [...T])
  : Promise<{
    [K in keyof T]: T[K] extends Promise<infer I> ? I : T[K]
  }>
SAKATAROSAKATARO

Capitalize

文字列の最初の文字を大文字に変換し、それ以外はそのままにする Capitalize<T> を実装します。

https://github.com/type-challenges/type-challenges/blob/main/questions/00110-medium-capitalize/README.ja.md

正解

正解
type MyCapitalize<S extends string>
  = S extends `${infer F}${infer R}` ? `${Uppercase<F>}${R}` : S

前の問題で、テンプレートリテラル型でパターンマッチができることはわかったが、
テンプレートリテラル型の中でinferを並べると、先頭から最短でマッチするっぽい。

SAKATAROSAKATARO

Permutation

質問: Union 型を Union 型の値の順列を含む配列に変換する順列型を実装します。

正解

正解
type Permutation<Rest, RestCopy = Rest>
  = [Rest] extends [never]
    ? []
    : Rest extends Rest
      ? [Rest, ...Permutation<Exclude<RestCopy, Rest>>]
      : never

[Rest]RestDistributive Conditional Typesによって、分散されないようにしている。
分散されちゃうと何が嬉しくないかというと、T extends ~~~Tにneverが入ると問答無用でneverになっちゃうから。(number | stringnumberstringに分散されるように、neverは「「「無」」」に分散されるので)

Rest extends Restは逆にRestを分散して、Unionから型をひとつひとつ取り出している。
加えて、分散されていない元のUnionも使いたいので、RestCopy = Restで元の状態もバックアップしているというわけ。

この問題は、Distributive Conditional Types全部詰めみたいな良問だった。

SAKATAROSAKATARO

KebabCase

質問: キャメルケースもしくはパスカルケースの文字列を、ケバブケースに置換する方を実装します。

https://github.com/type-challenges/type-challenges/blob/main/questions/00612-medium-kebabcase/README.ja.md

正解

Lowercaseだけで書いた場合。

正解1
type KebabCase<S extends string> =
  S extends `${infer S1}${infer S2 extends Lowercase<string>}${infer Rest}`
    ? `${Lowercase<S1>}${KebabCase<`${S2}${Rest}`>}` :
  S extends `${infer S1}${infer S2}${infer Rest}`
    ? `${Lowercase<S1>}-${KebabCase<`${Lowercase<S2>}${Rest}`>}` : 
  S

Uncapitalizeも使う場合。(正解1の${infer S2 extends Lowercase<string>}${infer Rest}を、正解2では${infer S2 extends Uncapitalize<string>}に集約している)

正解2
type KebabCase<S extends string> =
  S extends `${infer S1}${infer S2 extends Uncapitalize<string>}`
    ? `${Lowercase<S1>}${KebabCase<S2>}` :
  S extends `${infer S1}${infer S2}`
    ? `${Lowercase<S1>}-${KebabCase<Uncapitalize<S2>>}` : 
  S

アルゴリズム

再帰でしかループできないので、再帰で実装する必要がある。

すっきりと記載するには、再帰ごとに2文字ずつ着目するやり方がよさそう。
再帰により文字列を先頭から走査するのだが、1回の再帰で先頭から2文字に着目して、1文字目と2文字目の間に"-"を入れるかどうかを判定し、2文字目以降を次の再帰の入力とする。

こうすると、エッジのケースでもうまくいく。

小文字化については、すべて小文字にしてしまって問題ないので、あまり考える必要はない。

TypeScriptの機能

  1. テンプレートリテラルの推論における、パターンマッチングの挙動。

先頭から1文字ずつマッチさせていく挙動だったり、最後に高々1つ""がマッチする挙動だったり……。仕様なのか、実装上の挙動なのかは不明。

  1. infer型の制約

infer S2 extends Uncapitalize<string>の部分だが、TypeScript4.7からinfer型に制約を付けることができ、型を絞り込んでから、推論した型を取り出せる。書き方がちょっとすっきりする。

SAKATAROSAKATARO

AnyOf

Implement Python liked any function in the type system. A type takes the Array and returns true if any element of the Array is true. If the Array is empty, return false.

https://github.com/type-challenges/type-challenges/blob/main/questions/00949-medium-anyof/README.md

正解

正解
type Falsy = 
  | 0
  | ''
  | false
  | []
  | {[K in PropertyKey]: never}
  | undefined
  | null

type AnyOf<T extends readonly any[]> = T extends [infer F, ...infer Rest]
  ? F extends Falsy ? AnyOf<Rest> : true
  : false

ポイント

1. 空の{}だけを代入可能な型

Index SignatureかMapped Typesで、キーになりうるものすべての型をneverにする。

Index Signatureの場合
const a : {[key: PropertyKey]: never} = {a: "hoge"} // エラー
Mapped Typesの場合
const a : {[K in PropertyKey]: never} = {a: "hoge"} // エラー

ちなみに、{}でいけるのでは?と思うかもしれないが、{}はプロパティを持ちうる値なら何でも代入できる型なので、ここでは使えない。

誤り
const a : {} = {a: "hoge"} // エラーにならない。

https://typescriptbook.jp/reference/values-types-variables/object/difference-among-object-and-object

2. Truthy/Falsyな値のリテラル型の判定

ちょっと調べた感じだと、Falsyな値のリテラル型をハードコーディングする他になさそうだった。

Falsy
type Falsy = 
  | 0
  | ''
  | false
  | []
  | {[K in PropertyKey]: never}
  | undefined
  | null