type-challengesとは?
type-challenges は、TypeScript の「型レベルのプログラミング」を学ぶためのオープンソースの問題集です。
通常のコーディング問題とは違い、型システムだけを使ってパズルのような問題を解くのが特徴です。
- コードを書くのではなく 型を実装する
- エディタ上で型エラーがなくなると「正解」
- 難易度は 初級・中級・上級・最上級 に分類
- 実務でよく使うユーティリティ型の仕組みも理解できる
例として「Promise の中身を取り出す型」「配列の最後の要素を取得する型」など、普段なんとなく使っている型推論の裏側を自分で実装してみることで、TypeScript の理解が深まります。
私は記事の執筆現時点で中級までしか解いていませんが、自分で実装してみると「この型って意外とシンプルに実装できるんだな」という発見があったりします。あと単純に解いてて楽しいです。
この記事では、実際に解いてみて印象に残った問題を3つ紹介しつつ、ポイントを解説していきます。
1問目:PickByType
与えられたオブジェクト型
Tから、プロパティの型がUに割り当て可能(assignable)なプロパティだけを取り出して新しい型を作ってください。
例:
type OnlyBoolean = PickByType<{
name: string
count: number
isReadonly: boolean
isEnable: boolean
}, boolean>
// 結果: { isReadonly: boolean; isEnable: boolean; }
(出典: type-challenges の問題ページ) (GitHub)
この問題のポイント
1) 「割り当て可能(assignable)」の意味を正しく理解する
T[K] extends U の判断は “T[K] が U に割り当て可能か” を見ています。
例えば string extends string | number は真なので string は string | number に割り当てられますが、逆は偽です。条件の向き(左が具象、右が抽象)を間違えると用途が逆になります。
2) マップ型 + キー再マッピングが最もシンプル
期待される実装はキーごとに条件を判定し、合格したキーだけを残すパターンが簡潔です。実装例:
type PickByType<T, U> = {
[K in keyof T as T[K] extends U ? K : never]: T[K]
}
-
K in keyof Tで全キーを走査 -
as T[K] extends U ? K : neverで合格しないキーをneverにして結果から除外
この一行で「型が U に割り当てられるプロパティだけを抽出」できます。
2問目:Reverse
タプル型
Tを受け取り、その要素順を逆にした新しいタプル型を返してください。
例:
type R = Reverse<['a', 'b', 'c']>
// 結果: ['c', 'b', 'a']
(出典: type-challenges の問題ページ)
(GitHub)
この問題のポイント
1) 再帰 + タプル分解(infer)で実現する典型的なパターン
この問題は タプルを頭とそれ以外に分割する という TypeScript 型の基本テクニックがそのまま試されます。
よく使う分解パターンは次の形です:
T extends [infer Head, ...infer Rest]
これが 「先頭」と「残り」 を取り出す標準的な方法です。
タプル処理のほとんどがこの形の分解を起点に書かれています。
2) 「逆順」は“積み上げ”で作る
逆順にしたい場合は、再帰的に処理した残りのタプルの末尾に Head を追加するだけで OK です。
実装例:
type Reverse<T extends any[]> =
T extends [infer Head, ...infer Rest]
? [...Reverse<Rest>, Head]
: []
処理イメージ:
['a', 'b', 'c']- → Reverse(
['b', 'c']) に'a'を最後に追加 - → Reverse(
['c']) に'b'を追加 - → Reverse(
[]) で停止 - → 結果として
['c', 'b', 'a']が積み上がる
この「再帰しながら積む」スタイルは 配列操作の型問題の定番パターンなので覚えておくと応用が効きます。
3) 配列ではなく“タプル”が前提である点に注意
T extends any[] として書かれるものの、実質的には タプル(長さが決まった配列)であることが前提です。
通常の配列型(string[] のように長さが不定)を渡すと推論が曖昧になり、逆順になりません。
type-challenges では「タプル操作の練習問題」として出題されているので、その前提で理解して問題ありません。
3問目:Permutation
ユニオン型
Tを受け取り、
Tに含まれるすべての要素の順列(Permutation)をタプルのユニオンとして返してください。
例:
type perm = Permutation<'A' | 'B' | 'C'>
// 結果:
// | ['A', 'B', 'C']
// | ['A', 'C', 'B']
// | ['B', 'A', 'C']
// | ['B', 'C', 'A']
// | ['C', 'A', 'B']
// | ['C', 'B', 'A']
(出典: type-challenges の問題ページ)
(GitHub)
この問題のポイント
1) ユニオン型に対する条件型は「分配」される
T extends U ? X : Y のような 条件型をユニオンに適用すると、ユニオンの各要素に対して個別に評価される、という TypeScript の基本挙動を利用します。
例:
'A' | 'B' extends T ? ... : ...
これは内部的に:
('A' extends T ? ... : ...)
| ('B' extends T ? ... : ...)
のように展開されます。
Permutation の実装は、まさにこの 分配的条件型(distributive conditional types) を使ってユニオンを1要素ずつ取り出し、順列を構築します。
2) 「1つ取り出して、残りで再帰」が順列の本質
順列を作る基本ロジックは数学と同じで、
- ユニオンからある要素を1つ取り出す
- 残りのユニオンに対して再帰し、タプルの前にその要素を追加する
- これをユニオンの全要素について行う
という流れです。
そのため、ほぼすべての実装はこんな形になります:
type Permutation<T, U = T> =
[T] extends [never]
? []
: U extends U
? [U, ...Permutation<Exclude<T, U>>]
: never
ここで重要なのは U extends U の部分。
これは ユニオンを分配させるためのトリック で、U の各要素ごとに分岐させます。
3) Exclude を使って「残りのユニオン」を作る
Exclude<T, U> は「T から U を取り除いたユニオン」を作ります。
例:
Exclude<'A' | 'B' | 'C', 'A'> = 'B' | 'C'
これを使うことで、
- 「今取り出した要素以外」
- 「残りの要素でまた再帰」
が自然に書けます。
最終的に:
-
'A'を頭にする順列 -
'B'を頭にする順列 -
'C'を頭にする順列
がすべて生成され、それらがユニオンとして結合されます。
Permutation が返すのがタプルの“ユニオン”なのはこのためです。
まとめ
今回はtype-challgesの中級から3問紹介してみました。
type-challenges を解いてみて感じたのは、
「型を読む力は、型を書く力から生まれる」
ということでした。
実務のコードレビューでも、型の意図が理解しやすくなり、
「これって実はこういう仕組みで動いてるんだな」という気付きが増えた気がします。
中級までは比較的取り組みやすい問題が揃っているので、
TypeScript の型をもっと深く理解したい人にはとても良いトレーニングになると思います!
もしこの記事で紹介した問題が少しでも面白いと感じたら、
ぜひ type-challengesをいくつか解いてみてください!