【TypeScript と友達に】Type Challenges を全問解いたのでエッセンスと推し問題を紹介してみる - 前編
はじめに
自己紹介
初めまして、@kakekakemiya と申します。
現在は東京大学大学院学際情報学府の修士 1 年で、フロントエンドエンジニアとして活動しています(25 卒の就活生です)。TypeScript × React × Next.js が主戦場ですが、Flutter でモバイルアプリを開発するときもあります。
今回は自分の TypeScript 力の向上のために、合計 170 問あるType Challenges を全問解いたので、Type Challenges における推し問題を紹介しながら、型パズルを解く上でのエッセンスを述べていく記事を書いてみようと思います。
Zenn での初投稿なので、何かと至らない点があるかもしれませんが、よろしくお願いします。
この記事の想定読者
- TypeScript の型システムに興味がある方
- Type Challenges が気になっている方・解いている方
- 基本的な型の使い方には馴染みがあるが、generics, infer, extends, union の分配則などの高度な型の使い方には馴染みがない方
- 自作でユーティリティ型を書いてみたい方
- TypeScript の型の読解力(特に複雑なライブラリを読む力とか)を向上させたい方
Type Challenges とは
概要
Type Challenges は、TypeScript における型パズルを解く(与えられた「お題」をみたす型を実装する)ことで、TypeScript の型システムについて学べる問題集です。
TypeScript のチューリング完全な型システムの力で
高品質な型は潜在的なバグを回避しつつ、プロジェクトの保守性を向上させるのに役立ちます。
TypeScript には ts-toolbelt, utility-types, simplytyped など優れた型ユーティリティライブラリがあり、私たちは多くの後押しを得ているはずです。このプロジェクトは、型システムがどのように動作するのかを理解したり、独自の型ユーティリティを書いたり、課題へのチャレンジを楽しむことをサポートします。また、実際の業務で直面した問題を質問したり、その答えを得られるコミュニティを作りたいと考えています。 - そこでの問題が課題集に追加されるかもしれません!
(type-challenges/README.ja.md
より引用)
と、公式の README には書いてあります。
そう言われてもピンとこない方もいると思うので、例題として、問 4: Pick を見てみましょう。
以下のように、仕様・実装の雛形・テストケースが与えられます。この問題は、組み込みの型ユーティリティである Pick
を、別の型 MyPick
として自力で実装するというものです。
/*
4 - Pick
-------
by Anthony Fu (@antfu) #初級 #union #built-in
### 質問
組み込みの型ユーティリティ`Pick<T, K>`を使用せず、`T`から`K`のプロパティを抽出する型を実装します。
> GitHubで確認する:https://tsch.js.org/4/ja
*/
/* _____________ ここにコードを記入 _____________ */
type MyPick<T, K> = any;
/* _____________ テストケース _____________ */
import type { Equal, Expect } from "@type-challenges/utils";
type cases = [
Expect<Equal<Expected1, MyPick<Todo, "title">>>,
Expect<Equal<Expected2, MyPick<Todo, "title" | "completed">>>,
// @ts-expect-error
MyPick<Todo, "title" | "completed" | "invalid">
];
interface Todo {
title: string;
description: string;
completed: boolean;
}
interface Expected1 {
title: string;
}
interface Expected2 {
title: string;
completed: boolean;
}
問 4 Pick より一部改変
普段から業務などで TypeScript の型パズルを解いている方からすると簡単かもしれませんが、そうでない方にとっては、「そういえばどうやって実装されているんだろう」と考えさせられるいい問題だと思います。
「ただ組み込みの型ユーティリティを再発明し続けるのか?」というと全然そんなことはなく、色々な型を実装する課題を解くことができます。
わかりやすい例でいうと
camelCase -> snake_case という変換を行う型を実装するという割と実務に使えそうな問題
type Res1 = SnakeCase<"hello">; // => "hello"
type Res2 = SnakeCase<"userName">; // => "user_name"
type Res3 = SnakeCase<"getElementById">; // => "get_element_by_id"
から、
type T0 = Multiply<2, 3>; // '6'
type T1 = Multiply<3, "5">; // '15'
type T2 = Multiply<"4", 10>; // '40'
type T3 = Multiply<0, 16>; // '0'
type T4 = Multiply<"13", "21">; // '273'
type T5 = Multiply<"43423", 321543n>; // '13962361689'
Number Like な値を 2 つ受け取り、その積を返す型を実装するという、「型でやる必要はあるのか?(← Type Challenges をやってるときにこれを言ったらおしまい)」って思ってしまうような問題まで、様々な問題があります。(もっと変わり種だと、ハノイの塔のパズルのソルバーを型で書く問題なんかもありました。)
このように、TypeScript の型に関する知識を深めることができる問題が、Type Challenges にはたくさんあります。
これらの問題を解くことで、「型力」 が向上することは間違いありません。
問題の構成
記事執筆時点(2023/12)では Type Challenges は、
- お試し(1 問)
- 初級(13 問)
- 中級(95 問)
- 上級(47 問)
- 最上級(14 問)
の合計 170 問の問題があります。
(type-challenges/README.ja.md
のスクリーンショット)
それぞれの難易度は
- お試し:チュートリアル
- 初級:初級と言いつつ十分難しい。ここまでで解ければ正直まあ十分?
- 中級:「教育的にいい問題」と「趣味だなーって問題」が入り交じる。ハノイの塔のパズルはここ
- 上級:これ型の責務じゃないよなーという自分を騙しつつやる。趣味の領域
- 最上級:型の複合は必須で、型ごとのユニットテストとか書きたくなるレベル。逆に実用的なのかもしれない?
といった感じです。
これを聞いて、「初級っていいつつ難しいのか...」「170 問もあるのか…」と思った方もいるかもしれません。また、全ての問題を解く時間を確保するのが難しい方も少なくないと思います(僕もこれを全部解き切るために 1 週間ちょっとの時間を捧げました)。
そこで本記事では、全ての問題に取り組んだ私が、Type Challenges における推し問題を解説しながら、型パズルを解く上でのエッセンスを紹介していこうと思います!!
解答例
こんな感じで解答例が書かれます。
推し問題とエッセンスの紹介
内容的に面白いもの、学びの多いもの、実用的なもの、解答があまりに華麗なものなどなど、「これ解いてよかったなあ」って思える問題を紹介します。その問題の解説を通し、型パズルを解く上でのエッセンスについて書いていけたらと思います。
また、TypeScript 公式含む OSS 上での実装や、参考になる記事へのリンクを意識的に多めに貼っているので、ぜひご活用下さい。(参照後はこの記事に戻ってきてもらえると嬉しいです笑)
各難易度ごとに見ていきます。(「お試し」は 1 問しかないので割愛します。)
また、この章において特段明記がない場合は、問題は Type Challenges からの引用となります。
初級編
4: Pick
/*
4 - Pick
-------
by Anthony Fu (@antfu) #初級 #union #built-in
組み込みの型ユーティリティ`Pick<T, K>`を使用せず、`T`から`K`のプロパティを抽出する型を実装します。
*/
interface Todo {
title: string;
description: string;
completed: boolean;
}
type TodoPreview = MyPick<Todo, "title" | "completed">;
const todo: TodoPreview = {
title: "Clean room",
completed: false,
};
先ほど例題としても紹介した Pick を実装するという問題です。初級の第一問目となっていますが、その立場にふさわしい良問だと思います。
とりあえず解答例を見てみましょう。
解答例
type MyPick<T, K extends keyof T> = { [P in K]: T[P] };
keyof T
は、T
のプロパティ名のユニオン型を返します。つまり、K
は T
のプロパティ名のユニオン型を受け取ることになります。
[P in K]: T[P]
は、K
の各要素 P
に対して、T[P]
を返すという意味です。T[P]
は、T
のプロパティ P
の型を返します。
ここで、ジェネリクス K
についている、K extends keyof T
というのがポイントです。これは、K
が T
のプロパティ名のユニオン型を extends していることを意味します。つまり、K
は T
のプロパティ名のユニオン型の部分集合であることが保証されます。
これがない場合、例えば、MyPick<Todo, 'title' | 'completed' | 'invalid'>
のように、T
に存在しないプロパティ名を指定してしまうことができてしまいます。加えて、K extends keyof T
があることで、MyPick
の第二引数を書く際に、補完が効いて、T
のプロパティ名のユニオン型のメンバーしか指定できないようになります。便利ですね。
(ただし、より厳密には、[P in K]
を T[P] という T の key として使おうとする際に、互換性の無さから MyPick
の実装として怒られます。)
Essence
- ジェネリクスに extends をつけると、型に制約を設けることができる
- keyof T は、T のプロパティ名のユニオン型を返す
(ちなみにですが、TypeScript の公式の実装 も同じです。)
14: First of Array
/*
14 - First of Array
-------
by Anthony Fu (@antfu) #初級 #array
### 質問
配列`T`を受け取り、その最初のプロパティの型を返す`First<T>`を実装します。
*/
/* _____________ ここにコードを記入 _____________ */
type First<T extends any[]> = any;
/* _____________ テストケース _____________ */
import type { Equal, Expect } from "@type-challenges/utils";
type cases = [
Expect<Equal<First<[3, 2, 1]>, 3>>,
Expect<Equal<First<[() => 123, { a: string }]>, () => 123>>,
Expect<Equal<First<[]>, never>>,
Expect<Equal<First<[undefined]>, undefined>>
];
type errors = [
// @ts-expect-error
First<"notArray">,
// @ts-expect-error
First<{ 0: "arrayLike" }>
];
こちらは、与えられた配列の 1 つ目の要素を返す型です。
以下が実装例になります。
解答例
type First<T extends unknown[]> = T extends [] ? never : T[0];
// 以下でも可
type First<T extends unknown[]> = T extends [infer U, ...unknown[]] ? U : never;
T extends [] ? never : T[0]
は、T
が空配列の場合は never
を返し、そうでない場合は T[0]
を返すという意味です。いわゆる三項演算子ですね。
一方で、T extends [infer U, ...unknown[]] ? U : never
は、T
が [infer U, ...unknown[]]
にマッチした場合は U
を返し、そうでない場合は never
を返すという意味です。infer
は、Inferring within Conditional Types で、T
が [infer U, ...unknown[]]
にマッチした場合、U
に T
の要素の型を割り当てるという意味です。
一見難しそうですが、infer のマスターは型パズルを解く上で必須です。ぜひ覚えておきましょう。
Essence
- infer は、「ここに入る型って何?」ってのを推論してくれる便利な機能
- 一見複雑だが、実際のケースを想定するとそんなに難しくはない
infer のわかりやすい例として、組み込み型の ReturnType
を見てみましょう。
/**
* Obtain the return type of a function type
*/
type ReturnType<T extends (...args: any) => any> = T extends (
...args: any
) => infer R
? R
: any;
ReturnType は、関数(T extends (...args) => any
)を受け取り、その返り値の型を返す型です。
T extends (...args: any) => infer R ? R : any
は、T
が (...args: any) => infer R
にマッチした場合は R
を返し、そうでない場合は any
を返すという意味です。つまり、T
が関数の型の場合、その返り値の型を返すことができます。
type R1 = ReturnType<() => string>; // string になる
type R2 = ReturnType<() => string | null>; // string | null になる
type R3 = ReturnType<(...args: any) => number | null>; // number | null になる
ここで、類題として、配列 T
を受け取り、2 つ目の要素の型を返す型 Second<T>
の実装について考えてみましょう。
type Second<T extends unknown[]> = any; // ここを実装
type R1 = Second<[1, 2, 3]>; // 2 になる
type R2 = Second<[1, "2", 3]>; // "2" になる
type R3 = Second<[1]>; // never になる
type R4 = Second<[]>; // never になる
この問題は、上の T[0]
のように固定値でアクセスする方針だと、配列長が 2 以上であるかの条件分岐が複雑になりますが、infer を使えば以下のように実装することができます。
type Second<T extends unknown[]> = T extends [unknown, infer U, ...unknown[]]
? U
: never;
189: Awaited
/*
189 - Awaited
-------
by Maciej Sikora (@maciejsikora) #初級 #promise #built-in
### 質問
Promise ライクな型が内包する型をどのように取得すればよいでしょうか。
例えば:`Promise<ExampleType>`という型がある場合、どのようにして ExampleType を取得すればよいでしょうか。
> この問題の元記事は [original article](https://dev.to/macsikora/advanced-typescript-exercises-question-1-45k4) by [@maciejsikora](https://github.com/maciejsikora) です。
> GitHubで確認する:https://tsch.js.org/189/ja
*/
/* _____________ ここにコードを記入 _____________ */
type MyAwaited<T> = any;
/* _____________ テストケース _____________ */
import type { Equal, Expect } from "@type-challenges/utils";
type X = Promise<string>;
type Y = Promise<{ field: number }>;
type Z = Promise<Promise<string | number>>;
type Z1 = Promise<Promise<Promise<string | boolean>>>;
type T = { then: (onfulfilled: (arg: number) => any) => any };
type cases = [
Expect<Equal<MyAwaited<X>, string>>,
Expect<Equal<MyAwaited<Y>, { field: number }>>,
Expect<Equal<MyAwaited<Z>, string | number>>,
Expect<Equal<MyAwaited<Z1>, string | boolean>>,
Expect<Equal<MyAwaited<T>, number>>
];
// @ts-expect-error
type error = MyAwaited<number>;
この問題は、Promise ライクな型が resolve された際の型を取得する MyAwaited
を実装する問題です。
解答例は以下です。
解答例
type MyAwaited<T extends PromiseLike<any>> = T extends PromiseLike<infer P>
? P extends PromiseLike<any>
? MyAwaited<P>
: P
: never;
実装について順に見ていきましょう。
まず、基本的な思想としては、PromiseLike
な型 T
が resolve された際の型(ここで any と書かれている部分)を取得する型を実装するというものです。
よって、
type MyAwaited<T extends PromiseLike<any>> = T extends PromiseLike<infer P>
? P
: never;
のような型が雛形になります。実際、これで、X
, Y
, T
のテストケースは通ります。ではなぜ Z
と Z1
が通らないのでしょうか?
Z
と Z1
をよく見てみると、Promise<Promise<string | number>>
と Promise<Promise<Promise<string | boolean>>>
というように、PromiseLike<any> な型がネストしていることがわかります。よって、P
が PromiseLike<any>
な型の場合、再帰的に MyAwaited
を呼び出す必要があります。
よって、T extends PromiseLike<infer P> ? P : never
で P
を返してしまうのではなく、P
がまだ PromiseLike<any>
な型の場合は再帰的に MyAwaited
を呼び出し、そうでない場合は再帰を終了しP
を返すというようにすれば良いです。
よって最終的な実装は以下のようになります。
type MyAwaited<T extends PromiseLike<any>> = T extends PromiseLike<infer P>
? P extends PromiseLike<any>
? MyAwaited<P>
: P
: never;
Essence
- 型パズルにおいて、再帰的な型の定義は非常に強力な武器になる
- 終了判定条件をうまく設定することが重要
ちなみに、公式の Awaited
の実装は こちら です。
898: Includes
/*
898 - Includes
-------
by null (@kynefuk) #初級 #array
### 質問
JavaScriptの`Array.include`関数を型システムに実装します。この型は、2 つの引数を受け取り、`true`や`false`を出力しなければなりません。
> GitHubで確認する:https://tsch.js.org/898/ja
*/
/* _____________ ここにコードを記入 _____________ */
type Includes<T extends readonly any[], U> = any;
/* _____________ テストケース _____________ */
import type { Equal, Expect } from "@type-challenges/utils";
type cases = [
Expect<
Equal<Includes<["Kars", "Esidisi", "Wamuu", "Santana"], "Kars">, true>
>,
Expect<
Equal<Includes<["Kars", "Esidisi", "Wamuu", "Santana"], "Dio">, false>
>,
Expect<Equal<Includes<[1, 2, 3, 5, 6, 7], 7>, true>>,
Expect<Equal<Includes<[1, 2, 3, 5, 6, 7], 4>, false>>,
Expect<Equal<Includes<[1, 2, 3], 2>, true>>,
Expect<Equal<Includes<[1, 2, 3], 1>, true>>,
Expect<Equal<Includes<[{}], { a: "A" }>, false>>,
Expect<Equal<Includes<[boolean, 2, 3, 5, 6, 7], false>, false>>,
Expect<Equal<Includes<[true, 2, 3, 5, 6, 7], boolean>, false>>,
Expect<Equal<Includes<[false, 2, 3, 5, 6, 7], false>, true>>,
Expect<Equal<Includes<[{ a: "A" }], { readonly a: "A" }>, false>>,
Expect<Equal<Includes<[{ readonly a: "A" }], { a: "A" }>, false>>,
Expect<Equal<Includes<[1], 1 | 2>, false>>,
Expect<Equal<Includes<[1 | 2], 1>, false>>,
Expect<Equal<Includes<[null], undefined>, false>>,
Expect<Equal<Includes<[undefined], null>, false>>
];
この問題は、初級におけるボスと言えると思います。JavaScript の Array.prototype.includes を型システムに実装する問題です。
解答例は以下です。
解答例
type IsEqual<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y
? 1
: 2
? true
: false;
type Includes<T extends any[], U> = T extends [infer F, ...infer R]
? IsEqual<F, U> extends true
? true
: Includes<R, U>
: false;
一旦、IsEqual
は無視して、Includes
について考えてみましょう。
Includes
は、T
が [infer F, ...infer R]
にマッチした場合は、F
と U
が等しいかどうかを判定し、等しい場合は true
を返し、そうでない場合は R
について再帰的に Includes
を呼び出すというものです。こうすることで、配列を先頭から pop していき、配列全体を走査することができます。
そして、T
が [infer F, ...infer R]
にマッチしない場合は、false
を返します。ここで、T
が [infer F, ...infer R]
を extends しないとは、T
が空配列であることを意味します。
※ これは TypeScript の癖がある部分なのですが、
[infer F, ...infer Rest]
のような書き方をすると、F
は要素として必須である一方、Rest
は配列であれば OK(空配列を含む)という意味になります。以下の挙動を見るとわかりやすいです。type Test<T extends unknown[]> = T extends [infer F, ...infer Rest] ? [F, Rest] : never; type T1 = Test<[]>; // never type T2 = Test<[1]>; // [1, []] type T3 = Test<[1, 2]>; // [1, [2]] type T4 = Test<[1, 2, 3]>; // [1, [2, 3]] type T5 = Test<[1, 2, 3, 4]>; // [1, [2, 3, 4]]
この Includes
部分が書ければ十分初級としては素晴らしいと思うのですが、この問題の厄介なところは、IsEqual
という型が必要になるところです。
IsEqual
は、X
と Y
が等しいかどうかを判定する型です。これは、X
と Y
が等しい場合は true
を返し、そうでない場合は false
を返します。
であれば、「X
が Y
に割り当て可能であり、かつ Y
が X
で割り当て可能であれば true
」でよさそうですから、IsEqual
は以下のように実装できそうです。
type IsEqual<X, Y> = X extends Y ? (Y extends X ? true : false) : false;
しかし、この実装だと、以下のようなケースで不具合が置きます。
type IsEqual<X, Y> = X extends Y ? (Y extends X ? true : false) : false;
type A = IsEqual<{ a: "A" }, { readonly a: "A" }>; // false ではなく true
type B = IsEqual<never, never>; // true ではなく never
type C = IsEqual<string, any>; // false ではなく boolean
これについて深掘るとそれだけで 1 記事かけてしまいそうなので、ここでは割愛しますが、この問題の issue には、この問題についての議論がされています。興味がある方はぜひご覧ください。
上に挙げた不具合を解決するためには、IsEqual
の実装を以下のようにする必要があります。
type IsEqual<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y
? 1
: 2
? true
: false;
(豆知識ですが、Type Challenges のテストケースの正誤判定に使っている Equal
型も 同様の実装 となっています。)
Essence
- 配列(や文字列)を走査するには、infer を用いた再帰的な型の定義が必要
- Equal 型の実装は実直にやると落とし穴があるので、注意が必要
書いてみたら思ったよりボリューミーになってきたので、初級編はこの辺で終わりにします。初級編を解くだけでも開発者としての型力はかなり上がると思うので、ぜひ挑戦してみてください。
中級編
3: Omit
/*
3 - Omit
-------
by Anthony Fu (@antfu) #中級 #union #built-in
### 質問
組み込みの型ユーティリティ`Omit<T, K>`を使用せず、`T`のプロパティから`K`を削除する型を実装します。
> GitHubで確認する:https://tsch.js.org/3/ja
*/
/* _____________ ここにコードを記入 _____________ */
type MyOmit<T, K> = any;
/* _____________ テストケース _____________ */
import type { Equal, Expect } from "@type-challenges/utils";
type cases = [
Expect<Equal<Expected1, MyOmit<Todo, "description">>>,
Expect<Equal<Expected2, MyOmit<Todo, "description" | "completed">>>
];
// @ts-expect-error
type error = MyOmit<Todo, "description" | "invalid">;
interface Todo {
title: string;
description: string;
completed: boolean;
}
interface Expected1 {
title: string;
completed: boolean;
}
interface Expected2 {
title: string;
}
中級でご紹介する最初の問題は、Omit です。組み込みのユーティリティ型である Omit
を実装する問題です。
解答例
type MyOmit<T, K extends keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P];
};
[P in keyof T as P extends K ? never : P]
は、T
の各プロパティ P
に対して、P
が K
に extends する場合は never
を返し、そうでない場合は P
を返すという意味です。never
を返すことで、P
が K
に extends する場合は、P
が T
のプロパティ名のユニオン型の部分集合に含まれないようにします。
ここで重要なのは、P
に対して、as で型変数を割り当てることができるという点です。これにより、P
が K
に extends する場合は never
を返し、そうでない場合は P
を返すというような条件分岐を実現することができます。never
となった key は、最終的には削除されることになります。
単に MyOmit
を作るだけなら、公式の実装にならって以下のように書くこともできますが、自作の Util 関数で特定の key を削除したい場合などは、as を使った方が便利です。
// 公式の Omit の実装
/**
* Construct a type with the properties of T except for those in type K.
*/
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
Essence
- key に対して、as を使うと、key を変更することができる
- その際 never を返すと、その key は削除される
他の問題でもちょくちょく使うテクニックになります。おそらく、型にこだわりを持っている人は as を書くことにに抵抗が一定程度ある気がするのですが、この as は許されるものであると把握しておけると良いと思います。(なお、公式ドキュメントに、Key Remapping via as というページがあります。詳しくはこちらをご覧ください。)
12: Chainable Options(少々改題)
/*
12 - Chainable Options
-------
by Anthony Fu (@antfu) #中級 #application
### 質問
JavaScript では、チェイン可能なオプションがよく使われます。しかし、TypeScript に切り替えたとき、正しく型を付けることができますか?
この課題では、オブジェクトでもクラスでも何でもいいので、 `option(key, value)` と `get()` の 2 つの関数を提供する型を定義してください。`option` では、与えられたキーと値を使って現在の config の型を拡張できます。最終的な結果は `get` で取得することにしましょう。
この問題を解くために js/ts のロジックを書く必要はありません。型レベルのロジックだけを書いてください。
`key` は `string` のみを受け付け、`value` は任意の型を受け付けると仮定しても構いません。
同じ `key` を 2 回以上渡そうとするとエラーが発生するようにしてください。
> GitHubで確認する:https://tsch.js.org/12/ja
*/
/* _____________ ここにコードを記入 _____________ */
type Chainable = {
option(key: string, value: any): any;
get(): any;
};
/* _____________ テストケース _____________ */
import type { Alike, Expect } from "@type-challenges/utils";
declare const a: Chainable;
const result1 = a
.option("foo", 123)
.option("bar", { value: "Hello World" })
.option("name", "type-challenges")
.get();
const result2 = a
.option("name", "another name")
// @ts-expect-error
.option("name", "last name")
.get();
const result3 = a
.option("name", "another name")
// @ts-expect-error
.option("name", 123)
.get();
type cases = [
Expect<Alike<typeof result1, Expected1>>,
Expect<Alike<typeof result2, Expected2>>,
Expect<Alike<typeof result3, Expected3>>
];
type Expected1 = {
foo: number;
bar: {
value: string;
};
name: string;
};
type Expected2 = {
name: string;
};
type Expected3 = {
name: string;
};
この問題は、チェイン可能なオプションを実装する問題です。(ただし、同じ option
が渡された際の挙動についての挙動が納得行かないので、少しだけ改題してあります)以下が解答例です。
解答例
type Chainable<T extends object = {}> = {
option: <K extends string, V>(
key: K extends keyof T ? never : K,
value: V
) => Chainable<T & Record<typeof key, V>>;
get: () => T;
};
Chainable
は、T
という型のオブジェクトを受け取り、option
と get
という 2 つの関数を返す型です。option
は、key
と value
を受け取り、T
に key
と value
を追加した型を返します。get
は、T
を返します。
ここで重要なのは以下のポイントです。
-
option
を経由して、T
にkey
とvalue
を追加する。これは、Chain された分だけ反映させる必要がある。 -
Chainable
はoption
orget
の選択肢をもつ。 -
option
を呼び出したら Chain が続行され、get
を呼び出した場合はそれ以降は Chainable ではなくなる。 - 同じ key を渡した場合はエラーになる。
図示すると以下のようになります。
よって、以下のような雛形を考えることができます。
type Chainable<T extends object = {}> = {
option: <K extends string, V>(
key: K,
value: V
) => Chainable<T & Record<K, V>>;
get: () => T;
};
この時点でおおよそできているですが、同じ key を渡した場合はエラーになるという仕様をまだ満たせていません。
任意の string の key を受け付けつつ、すでに存在する key を渡した場合はエラーになるようにするにはどうすれば良いでしょうか?
仮に string ではなく、候補が限定される union であれば、K extends Exclude<P, keyof T>
(P
は候補)とかやれば良さそうですが、今回は任意の string を受け付けたいです、
これを達成するためには、key がすでに存在している場合にのみ、引数の型を never
にするということをすれば良いです。これは、K extends keyof T ? never : K
とすることで実現できます。このように書くと、引数の型 K
が keyof T
(すなわちすでに設定された option
)に含まれる場合、option の第一引数の型は never
になるので、option
の呼び出し時にエラーが発生します。
type Chainable<T extends object = {}> = {
option: <K extends string, V>(
key: K extends keyof T ? never : K,
value: V
) => Chainable<T & Record<typeof key, V>>;
get: () => T;
};
Essence
- うまく再帰を書けば、型できれいにチェイン可能なオプションを実装できる
- never を使うと、引数の型をうまく制限できるケースがある
どこまで実用的かはわかりませんが、型の可能性を広げるという意味では面白い問題だと思いますし、学びが多い問題だったのでご紹介しました。
これまた余談ですが、僕の推しライブラリである ts-pattern の Match 型 とかは、このチェイン可能なオプションをよく似たノリで実装していたりします。
108: Trim
/*
108 - Trim
-------
by Anthony Fu (@antfu) #中級 #template-literal
### 質問
文字列を受け取り、両端の空白を削除した新しい文字列を返す `Trim<T>` を実装します。
> GitHubで確認する:https://tsch.js.org/108/ja
*/
/* _____________ ここにコードを記入 _____________ */
type Trim<S extends string> = any;
/* _____________ テストケース _____________ */
import type { Equal, Expect } from "@type-challenges/utils";
type cases = [
Expect<Equal<Trim<"str">, "str">>,
Expect<Equal<Trim<" str">, "str">>,
Expect<Equal<Trim<" str">, "str">>,
Expect<Equal<Trim<"str ">, "str">>,
Expect<Equal<Trim<" str ">, "str">>,
Expect<Equal<Trim<" \n\t foo bar \t">, "foo bar">>,
Expect<Equal<Trim<"">, "">>,
Expect<Equal<Trim<" \n\t ">, "">>
];
この問題は、文字列の両端の空白を削除する Trim を実装する問題です。以下が解答例です。
解答例
type Space = " " | "\n" | "\t";
type Trim<S extends string> = S extends
| `${Space}${infer R}`
| `${infer R}${Space}`
? Trim<R>
: S;
この問題の思想はシンプルで、Space
型が前後に入っている場合 → まだ Trim
する必要があるので再帰的に Trim
を呼び出す、というものです。${Space}${infer R}
は、S
の先頭が ${Space}
にマッチした場合を表し、${infer R}${Space}
は、S
の末尾が ${Space}
にマッチした場合を表します。
ここで重要なのは、string に対する infer を行っている点です。TypeScript には Template Literal Types という機能があり、これを使うことで、string に対しても infer を行うことができます。
Template Literal Types は
type IPv4Address = `${number}.${number}.${number}.${number}`; // IP アドレスを表す型 type Url = `https://${string}`; // URL を表す型
みたいなノリで知っておくと色々活用できる便利な機能です。
詳しくは Template Literal Types をご覧ください。
Essence
- string に対しても infer を行うことができる(ただし、配列とは少し仕様が違ったりするので注意)
- Template Literal Types を知っておくと色々いいことがある
296: Permutation
/*
296 - Permutation
-------
by Naoto Ikuno (@pandanoir) #中級 #union
### 質問
Union 型を Union 型の値の順列を含む配列に変換する順列型を実装します。
> GitHubで確認する:https://tsch.js.org/296/ja
*/
/* _____________ ここにコードを記入 _____________ */
type Permutation<T> = any;
/* _____________ テストケース _____________ */
import type { Equal, Expect } from "@type-challenges/utils";
type cases = [
Expect<Equal<Permutation<"A">, ["A"]>>,
Expect<
Equal<
Permutation<"A" | "B" | "C">,
| ["A", "B", "C"]
| ["A", "C", "B"]
| ["B", "A", "C"]
| ["B", "C", "A"]
| ["C", "A", "B"]
| ["C", "B", "A"]
>
>,
Expect<
Equal<
Permutation<"B" | "A" | "C">,
| ["A", "B", "C"]
| ["A", "C", "B"]
| ["B", "A", "C"]
| ["B", "C", "A"]
| ["C", "A", "B"]
| ["C", "B", "A"]
>
>,
Expect<Equal<Permutation<boolean>, [false, true] | [true, false]>>,
Expect<Equal<Permutation<never>, []>>
];
union を受け取り、その順列を union で返す Permutation を実装する問題です。以下が解答例です。この問題は正直難しいのですが、union の仕様を学ぶ上で非常に教育的であるのでピックアップしました。
解答例
type Permutation<T, K = T> = [T] extends [never]
? []
: T extends T
? [T, ...Permutation<Exclude<K, T>>]
: never;
正直、???となる方もいらっしゃると思いますが、シンプルな例を出しながらこの実装について少しずつ解読していこうと思います。
union の分配則
まず簡単のために、以下のような、2 つの型の違いについて考えてみましょう。
type ArrayUnion1<T> = [T];
type ArrayUnion2<T> = T extends T ? [T] : never;
type A1 = ArrayUnion1<"a" | "b">; // ["a" | "b"]
type A2 = ArrayUnion2<"a" | "b">; // ["a"] | ["b"]
両者は、T extends T
が入っているかどうかの違いしかありませんが TypeScript における union は分配則(Distributive Conditional Types
)という特殊な特性を持っています。
union の型に対して、extends を行った場合、その時点で union は解体され、それ以降の処理結果の union となる機構です。簡単に行ってしまえば、以下のような処理の違いがあるということです。
type ArrayUnion1<T> = [T];
type ArrayUnion2<T> = T extends T ? [T] : never;
// A1 は ["a" | "b"] となる
type A1 = ArrayUnion1<"a" | "b">; // ["a" | "b"]
// A2 は、T extends T があることにより、処理自体が分離された union となるため ["a"] | ["b"] となる
type A2 = ArrayUnion2<"a" | "b">; // "a" extends "a" ? ["a"] : never | "b" extends "b" ? ["b"] : never
よって、今回の問題では、T extends T
があることにより、union が分離され、union の順列を求めることができます。ここまでを踏まえると、以下のような雛形が書けることがわかります。
type Permutation<T> = T extends T
? [T, ...Permutation<(受け取った union から、T を除いたもの)>]
: [];
残っている union を知りたい
では、残っている union をどうやって知れば良いでしょうか?union からある値を除きたいわけですから、Exclude
が使えそうです。しかし、これを満たすには、以下のように、分配前の T
と分配後の T
を両方知りたいという欲張りな要求をせねばなりません。
type Permutation<T> = T extends T
? [T, ...Permutation<Exclude<(受け取った T), (分配された T)>>]
: [];
この回避法として、K = T
というオプショナルな型を用意し、K
に分配前の T
の型を保存することを考えます。T
は extends するものの、K
には extends を当てないことで、今回の要求を満たすことができます。
type Permutation<T, K = T> = T extends T
? [T, ...Permutation<Exclude<K, T>>]
: [];
これで解決した気がしてきますが、どうやらテストケースが一つも通らないようです。現状の実装が何を返しているかを見てみると、なんと never
が返ってきています!?これはどういうことでしょうか?
type Permutation<T, K = T> = T extends T
? [T, ...Permutation<Exclude<K, T>>]
: [];
type A = Permutation<"a" | "b">; // never になってしまう!?!?
never の罠
先程の型の再帰の終了条件について考えてみましょう。今回の場合、最後の一個まで行ったあと、Exclude<"someThing", "someThing">
のように、一つの union からそれを Exclude した型が T
として渡されるので、最後の T
は never
となります。この、never
はなかなかの曲者なので、今回のようなよくわからない結果が出てしまっているのです。
ちょっと never
について深掘ってみましょう。
type IsNever<T> = T extends never ? true : false;
type Test1 = IsNever<"hello">; // false
type Test2 = IsNever<never>; // true になってほしいが...
上に、T
が never
かを判定する型として、IsNever
を書いてみました。Test1
は想定通り false
になりますが、Test2
は true
になってほしいものです。しかし、実際には信じられないことに、Test2
は never
になります!!。意味がわかりませんがこれは正しい挙動です。
これは、never
が union として解釈され、分配則に基づいて展開され、到達不能な場所として never
が返されてしまうためです。
(以下の例における Never
は never
になるが、What
は string | number | never
ではなく、string | number
となるといえばわかりやすいかもしれません。)
type ReturnT<T> = T extends T ? T : never;
type Never = ReturnT<never>; // Never = never となる
type What = ReturnT<string | number | never>; // What = string | number となる
これを踏まえ本問題の内容にもどると、先程の解答では、再帰の最後で never
が返ってきてしまい、全体の結果としても never
が返ってしまっているのです。これを回避するためには、never
の union としての分配を防ぐ必要があります。
never の分配を防ぐ
never
(というか union)の分配を防ぐには、それ自体を extends しなければ大丈夫です。簡単な手法としては、[] で包むことで、union の分配を防ぐことができます。
type IsNever<T> = [T] extends [never] ? true : false; // これは never かどうかの判定をちゃんとできる
よって、
type Permutation<T, K = T> = [T] extends [never]
? []
: T extends T
? [T, ...Permutation<Exclude<K, T>>]
: never;
のように、T
自体を分配する前に never
をハンドリングする処理を書けば、never
の分配を防ぐことができます。これで、全てのテストケースが通るようになりました。
Essence
- union に対して extends を行うと、union が分配される
- never は分配されると到達不能な場所として扱われる
- union の値判定はしたいが、分配したくない場合には、[] で包むといった工夫が必要
もうちょっとこのノリについて触れたい方は、1097: IsUnion、4260: 文字の組み合わせ という問題もありますので、ぜひ挑戦してみてください。(Permutation
との重複が大きいので見送りましたが、これらの問題も非常に良問です。)
27152: Triangular Number
/*
27152 - Triangular number
-------
by null (@aswinsvijay) #medium #tuple #array #math
### Question
Given a number N, find the Nth triangular number, i.e. `1 + 2 + 3 + ... + N`
> View on GitHub: https://tsch.js.org/27152
*/
/* _____________ Your Code Here _____________ */
type Triangular<N extends number> = any;
/* _____________ Test Cases _____________ */
import type { Equal, Expect } from "@type-challenges/utils";
type cases = [
Expect<Equal<Triangular<0>, 0>>,
Expect<Equal<Triangular<1>, 1>>,
Expect<Equal<Triangular<3>, 6>>,
Expect<Equal<Triangular<10>, 55>>,
Expect<Equal<Triangular<20>, 210>>,
Expect<Equal<Triangular<55>, 1540>>,
Expect<Equal<Triangular<100>, 5050>>
];
この問題は、与えられた非負整数 N
に対して、1 + 2 + 3 + ... + N
を求める Triangular を実装する問題です。以下が解答例です。
解答例
type Triangular<
N extends number,
Counter extends never[] = [],
Ans extends never[] = []
> = Counter["length"] extends N
? [...Ans, ...Counter]["length"]
: Triangular<N, [never, ...Counter], [...Ans, ...Counter]>;
この問題は、再帰的に Counter
と Ans
を更新していくことで、Counter
の長さが N
に達したら、Ans
の長さを返すという実装になっています。
実務で使うことがあまりなさそうなので、あえてここまで紹介していなかったのですが、型レベルで数字を扱うのはなかなか厄介です。というのも、基本的にシンプルに型で数を扱うには、配列長で表現するしかないからです。なので、例えば文字列長を数える型を作るためには、一度配列に変換してから、その長さを数えるというようなことをしなければなりません。
なので数字を文字列として解釈・解析する加算機などを作れば話は別ですが(← 最上級の問題にあります!掛け算も実装します!)型レベルで数を扱うのはなかなか難しいです。
ただ、数字を扱う問題を一問も紹介しないのはなんか違う気がしたので、ここで紹介します。
Counter
, Ans
はそれぞれ never
の配列(配列長のみを意識するので、ここはなんでもいいです。僕は意味ないぜ感を出したいので never[]
をよく使います。)であり、Counter
は N
に達するまで、never
を追加していきます。Ans
は、Counter
の長さ分をそのときの Ans
に足していきます([...Ans, ...Counter]
が足し算に相当)。
Essence
- 型レベルで数を扱うのはなかなか厄介
- どうしても扱いたい場合、基本的には配列長で表現するしかない
- 加えて、大きい数を扱う際は、再帰上限にひっかかるケースが多いのでより高度な型の実装が必要になる
再帰上限については、最上級編の解説でもう少し詳しく触れます。
上級編
次は上級編を見ていきます。上級編になってくると、いつ役に立つのか?みたいな特殊ケースに対応するような問題が増えてきますが、そんななかでも、型についての見識を深められるような問題をピックアップしていきます。
と、これ以降の内容を一旦書いてみたのですが、あまりにも記事が長くなってしまったので、続きは以下のの記事に分けさせていただくことにしました。 初級中級編も十分学びが多いですが、上級編以降はより高度な型の扱いについて学べる問題が多いので、ぜひご覧ください!
(最後まで読んでいただきありがとうございました!内容が面白かったという方は、後編編に移動する前に「いいね」をしてくださると筆者が本当に喜びます 🙏)
Discussion