type-challengesで難しかったものをメモ
First of Array
質問: 配列
T
を受け取り、その最初のプロパティの型を返すFirst<T>
を実装します
誤答
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
Exclude
質問: 組み込みの型ユーティリティ
Exclude <T, U>
を使用せず、U
に割り当て可能な型をT
から除外する型を実装します。
正解
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')
Awaited
質問: Promise ライクな型が内包する型をどのように取得すればよいでしょうか。
誤答
// 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型っぽい。
Includes(実質Equalの話)
質問: JavaScriptの
Array.include
関数を型システムに実装します。この型は、2 つの引数を受け取り、true
やfalse
を出力しなければなりません。
正解
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のユーティリティ関数なのだが、T
がU
と同じ型ならばtrue型
を、違う方ならばfalse型
を、リテラル型として返す。
「Equal
も自分で実装したほうがいいのかな?」と思って、挑戦したが、うまく行かず、調べてみると、闇が深かった。
Omit
質問: 組み込みの型ユーティリティ
Omit<T, K>
を使用せず、T
のプロパティからK
を削除する型を実装します。
正解(組み込み型ユーティリティのPickとExcludeを使う)
type MyOmit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>
正解(組み込み型ユーティリティを使わない)
導き方としては、正解1に対して、PickとExcludeの式を展開すればいいのだが、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ですね。キーを上書きする機能。
今回だと、キーはkeyof T
から取り出された一つ一つのPで、as P extends K ? never : P
で一塊になっていて、K
に代入可能ならばnever
(つまりキーから取り除く)という意味。
Deep Readonly
質問: オブジェクトのすべてのパラメーター(およびそのサブオブジェクトを再帰的に)を読み取り専用にする
DeepReadonly<T>
を実装します。この課題ではオブジェクトのみを扱っていると想定してください。配列、関数、クラスなどは考慮する必要はありません。しかし、可能な限り様々なケースをカバーすることで、自分自身に挑戦することができます。
誤答
type DeepReadonly<T> = {
readonly [P in keyof T]: DeepReadonly<T[P]>
}
この誤答は、無条件に再帰していくという方式。
一見すると良さげだが、パラメータに関数型があると、{}
に変換されてしまう。
DeepReadonly<{a: () => 22}> // {readonly a: {}}になる。
正解
取れるアプローチは様々なのだが、ここでは紹介するアプローチは2つ。
- 関数の時だけ特別扱い。
-
{}
になるとき、つまりキーがnever
になるときだけ特別扱い。
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends Function ? T[P] : DeepReadonly<T[P]>
}
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"
にと、そのままになる。
Chainable Options
質問: JavaScript では、チェイン可能なオプションがよく使われます。しかし、TypeScript に切り替えたとき、正しく型を付けることができますか?この課題では、オブジェクトでもクラスでも何でもいいので、
option(key, value)
とget()
の 2 つの関数を提供する型を定義してください。option
では、与えられたキーと値を使って現在の config の型を拡張できます。最終的な結果はget
で取得することにしましょう。
誤答
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
のところ。
Promise.all
質問: Promise ライクなオブジェクトの配列を受け取る関数
PromiseAll
に型を付けてください。戻り値はPromise<T>
である必要があります。ここで、T
は解決された結果の配列です。
正解
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点。
[...T]
- 配列の各要素に適用するための
{[K in keyof T]: UnpackPromise<T[K]>}
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を使うと、エレガントに書ける。
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
を使えばよい。
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]
}>
Trim Left
質問: 文字列を受け取り、先頭の空白を削除した新しい文字列を返す
TrimLeft<T>
を実装します。
正解
type TrimLeft<S extends string> = S extends `${" "|"\n"|"\t"}${infer R}` ? TrimLeft<R> : S
テンプレートリテラル型でコンパイル時に文字列のパターンマッチもできるんだな……。
Capitalize
文字列の最初の文字を大文字に変換し、それ以外はそのままにする
Capitalize<T>
を実装します。
正解
type MyCapitalize<S extends string>
= S extends `${infer F}${infer R}` ? `${Uppercase<F>}${R}` : S
前の問題で、テンプレートリテラル型でパターンマッチができることはわかったが、
テンプレートリテラル型の中でinfer
を並べると、先頭から最短でマッチするっぽい。
Permutation
質問: Union 型を Union 型の値の順列を含む配列に変換する順列型を実装します。
正解
type Permutation<Rest, RestCopy = Rest>
= [Rest] extends [never]
? []
: Rest extends Rest
? [Rest, ...Permutation<Exclude<RestCopy, Rest>>]
: never
[Rest]
はRest
がDistributive Conditional Typesによって、分散されないようにしている。
分散されちゃうと何が嬉しくないかというと、T extends ~~~
のT
にneverが入ると問答無用でneverになっちゃうから。(number | string
がnumber
とstring
に分散されるように、neverは「「「無」」」に分散されるので)
Rest extends Rest
は逆にRest
を分散して、Unionから型をひとつひとつ取り出している。
加えて、分散されていない元のUnionも使いたいので、RestCopy = Rest
で元の状態もバックアップしているというわけ。
この問題は、Distributive Conditional Types全部詰めみたいな良問だった。
KebabCase
質問: キャメルケースもしくはパスカルケースの文字列を、ケバブケースに置換する方を実装します。
正解
Lowercase
だけで書いた場合。
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>}
に集約している)
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つ""
がマッチする挙動だったり……。仕様なのか、実装上の挙動なのかは不明。
-
infer
型の制約
infer S2 extends Uncapitalize<string>
の部分だが、TypeScript4.7からinfer
型に制約を付けることができ、型を絞り込んでから、推論した型を取り出せる。書き方がちょっとすっきりする。
AnyOf
Implement Python liked
any
function in the type system. A type takes the Array and returnstrue
if any element of the Array is true. If the Array is empty, returnfalse
.
正解
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
にする。
const a : {[key: PropertyKey]: never} = {a: "hoge"} // エラー
const a : {[K in PropertyKey]: never} = {a: "hoge"} // エラー
ちなみに、{}
でいけるのでは?と思うかもしれないが、{}
はプロパティを持ちうる値なら何でも代入できる型なので、ここでは使えない。
const a : {} = {a: "hoge"} // エラーにならない。
2. Truthy/Falsyな値のリテラル型の判定
ちょっと調べた感じだと、Falsyな値のリテラル型をハードコーディングする他になさそうだった。
type Falsy =
| 0
| ''
| false
| []
| {[K in PropertyKey]: never}
| undefined
| null
進捗
お試し
⇒初級
⇒中級
とやって、中級
の上から半分くらいまで解いた。
今後は、気が向いたらやる。