TypeScript type challengesを解いてみた
TypeScript type challengesを解いてみよう
Nakano as a Service
この記事は以下のSlidevで書かれたMarkdownのスライドをそのまま貼り付けたものです。
SpeakerDeck - TypeScript type challengesを解いてみよう
動機
- 我々の製品はフロントからバックエンドまでTypeScriptで統一している
- コードの品質を上げるためにはTypeScriptを深く知るのがよいのでは
- 座学的にドキュメントを読むのもいいが、問題を解きながらドキュメントを見ていくほうが実践的
→ そうだTypeScript type challengesをやろう
TypeScript type challengesとは
- TypeScript上の型定義を助けるユーティリティ型を自分で実装していく問題集
- ユーティリティ型を知ること自体が、TypeScriptでなにができるかを知ることに繋がる
- さらにそれらを自分で実装することで、TypeScriptの言語機能についてより深く知ることができる
問題は以下のGitHubリポジトリに公開されています
GitHub - TypeScript type challenges
流れ
- TypeScriptの成り立ち
- TypeScriptの型の基本
- TypeScript type challenges 初級編を解く
- ユーティリティ型の解説
- 解答解説
TypeScriptの成り立ち
JavaScriptには型が無い
function add(a, b) {
return a + b;
}
add(5, "10"); // 出力: "510"
add(5, [1, 2]); // 出力: "51,2"
add(5, null); // 出力: 5
add(5, undefined); // 出力: NaN
- 予期せぬ動作を起こす
- 実行するまで正常に動作するかわからない
- バグの温床
TypeScriptの成り立ち
JavaScriptの文法はそのままに型を書けるようにした
function add(a: number, b: number): number {
return a + b;
}
add(5, "10"); // エラー: 引数 'b' の型 '"10"' はパラメーター型 'number' に割り当てられません。
add(5, [1, 2]); // エラー: 引数 'b' の型 'number[]' はパラメーター型 'number' に割り当てられません。
add(5, null); // エラー: 引数 'b' の型 'null' はパラメーター型 'number' に割り当てられません。
add(5, undefined); // エラー: 引数 'b' の型 'undefined' はパラメーター型 'number' に割り当てられません。
- コンパイル時に型をチェックし、型の部分を消し去る(トランスパイルと言う)
function add(a, b) {
return a + b;
}
- 既存のJSコード資産に型を付け加えられる → それだけの柔軟性がある
柔軟性の入り口: ジェネリック型
ジェネリック型とは: 型を引数にとり型を返す関数
例: 数値型を中身に持つオブジェクト型を宣言したい(ジェネリック型無し)
type NumberBox = {
content: number
}
// 使用例
const box: NumberBox = {
content: 1
}
柔軟性の入り口: ジェネリック型
例: 文字列、日付、真理値型バージョンも用意したい(ジェネリック型無し)
type NumberBox = {
content: number
}
type StringBox = {
content: string
}
type DateBox = {
content: Date
}
type BooleanBox = {
content: boolean
}
...
これをやるのは少し大変 → ジェネリック型を使う
柔軟性の入り口: ジェネリック型
type Box<T> = {
content: T
}
type NumberBox = Box<number>
type StringBox = Box<string>
type DateBox = Box<Date>
type BooleanBox = Box<boolean>
// 使用例
const bbox: BooleanBox = { content: true }
const bbox: Box<boolean> = { content: true } // 直接書くこともできる
この <...>
の部分を型引数という
- ジェネリック型の型引数 → 関数でいう引数
-
ジェネリック型は関数の型バージョン
- 型を引数にとり型を返す関数
ジェネリック型の型引数にも型制約を書きたい
再掲: 関数の引数に対する型付け
function add(a: number, b: number): number {
return a + b;
}
これと同等のことをジェネリック型に対してもやりたい
extends
による型制約
type Box<T extends number> = {
content: T
}
type NumberBox = Box<number>
type StringBox = Box<string> // 型エラー
type DateBox = Box<Date> // 型エラー
type BooleanBox = Box<boolean> // 型エラー
これで Box<T>
の T
には number
しか入らなくなった。
→ 〜身体は強い型を求める〜
リテラル型とユニオン型
type Three = 3 // 3しか入らない型(リテラル型)
type Four = 4 // 4しか入らない型(リテラル型)
type ThreeOrFour = Three | Four // 3または4しか入らない型(ユニオン型)
const x: ThreeOrFour = 3
const y: ThreeOrFour = 4
const z: ThreeOrFour = 334 // 型エラー
これを用いて 3
か"four"
しか入らないBox型を作れる
type ThreeOrFourBox<T extends 3 | "four"> = {
content: T
}
type FourBox = ThreeOrFourBox<"four">
type FourBox = ThreeOrFourBox<5> // 型エラー
const x: FourBox = { content: "four" }
const x: FourBox = { content: 4 } // 型エラー
ここまでのまとめ
- TypeScriptはJavaScriptに型を後付けする言語
- ジェネリック型
<...>
は型を引数に取り型を返す関数 - 関数の引数に型をつけるように、ジェネリック型の型引数に型制約をつけた
- リテラル型で
3
や"foo"
しか入らない型を作った - ユニオン型
|
で3
または"foo"
しか入らない型を作った - あとは実践あるのみ
TypeScript type challengesを解いてみよう!
※ 問題文は簡単のために一部改変されています
初級1-Pick: 問題
組み込みの型ユーティリティPick<T, K>
を使用せず、T
からK
のプロパティを抽出する型を実装します。
例えば:
type Todo = {
title: string
description: string
completed: boolean
}
type TodoPreview = MyPick<Todo, 'title' | 'completed'>
const todo: TodoPreview = {
title: 'Clean room',
completed: false,
}
初級1-Pick: 解説
まず型引数の制約から考える
type MyPick<T extends object, K extends keyof T> = { /* あとで書く */ }
T extends object
型引数T
はobject
型であり、string
型やnumber
型などではないという型制約
keyof T
: keyof型演算子
type Book = {
title: string;
price: number;
rating: number;
};
type BookKey = keyof Book;
// 上は次と同じ意味になる
type BookKey = "title" | "price" | "rating";
初級1-Pick: 解説
そしてK
をオブジェクト型のキーとすることを考える
type MyPick<T extends object, K extends keyof T> = {
[X in K]: X /* まだTを使っていない */
}
{ [X in K]: X }
: Mapped Types
型変数K
に与えられたユニオン型をfor文のように型変数X
に割り当て、X
をキーとするオブジェクト型を返す。またそのキーX
に対応する:
の右辺でX
が使える。
type AB = {
[X in "a" | "b"]: X
}
// これは以下と同義
type AB = {
a: "a",
b: "b"
}
初級1-Pick: 解説
完成形
type MyPick<T extends object, K extends keyof T> = {
[X in K]: T[X]
}
T[X]
: Indexed Access Types
オブジェクト型や配列型に対してX
型でインデックス参照した際に、返しうるプロパティや要素の型を返す。
type Todo = { // オブジェクト型の例
title: string
completed: boolean
}
type TitleOfTodo = Todo["title"] // stringになる
type TitleOfTodo = Todo[string] // string | booleanになる
type Arr = boolean[] // 配列型の例
type TypeOfArr = Arr[334] // booleanになる
type TypeOfArr = Arr[number] // 同じくbooleanになる
初級1-Pick: 解説
完成形
type MyPick<T extends object, K extends keyof T> = {
[X in K]: T[X]
}
例: T
に{a:number, b:string, c:boolean}
型、K
に"a" | "c"
型を割り当てた時
// Tだけを割り当てた
MyPick<{ a: number, b: string, c: boolean }, K extends "a" | "b" | "c">
// Kも割り当てた
MyPick<{ a: number, b: string, c: boolean }, "a" | "c">
=> {
[X in "a" | "c"]: { a: number, b: string, c: boolean }[X]
}
=> {
"a": { a: number, b: string, c: boolean }["a"],
"c": { a: number, b: string, c: boolean }["c"]
}
=> { a: number, c: boolean }
layout: center
ちなみにこれらの問題は以下のリンクから環境構築不要で問題を解き始められます!
GitHub - TypeScript type challenges
初級2-Readonly: 問題
組み込みの型ユーティリティReadonly<T>
を使用せず、T
のすべてのプロパティを読み取り専用にする型を実装します。実装された型のプロパティは再割り当てできません。
例えば:
type Todo = {
title: string
description: string
}
const todo: MyReadonly<Todo> = {
title: "Hey",
description: "foobar"
}
todo.title = "Hello" // Error: cannot reassign a readonly property
todo.description = "barFoo" // Error: cannot reassign a readonly property
初級2-Readonly: 解説
完成形
type MyReadonly<T extends object> = {
readonly [X in keyof T]: T[X]
}
- MappedTypesにはreadonly属性をつけられる。
T
に{ x: number, y: string }
型を与えたときの型演算の途中経過
例: MyReadonly<{ x : number, y: string }>
=> {
readonly [X in "x" | "y"]: { x: number, y: string }[X]
}
=> {
readonly x: { x: number, y: string }["x"],
readonly y: { x: number, y: string }["y"]
}
=> {
readonly x: number,
readonly y: string
}
初級3-Tuple to Object: 問題
タプルを受け取り、その各値のkey/valueを持つオブジェクトの型に変換する型を実装します。
例えば:
type Tuple = ['tesla', 'model 3', 'model X', 'model Y']
type Result = TupleToObject<Tuple>
const r: Result = {
'tesla': 'tesla',
'model 3': 'model 3',
'model X': 'model X',
'model Y': 'model Y'
}
初級3-Tuple to Object: 解説
そもそもタプル型とは
-
要素数と各要素の型が決まっている配列型
-
ただの配列型の例:
number[]
,string[]
,"a"[]
,1[]
-
タプル型の例:
[number, string]
、["a", 1, true]
-
配列型をさらに厳しくしたもの:
[number, string] extends any[]
-
リテラル表記の配列の型がタプル型だと主張したい時は
[...] as const
と書く
type T = ['tesla', 'model 3', 'model X', 'model Y'] // タプル型の宣言
const teslaArr: string[] = ['tesla', 'model 3', 'model X', 'model Y']
const teslaTuple: T = ['tesla', 'model 3', 'model X', 'model Y'] // これはエラー(リテラル表記の配列)
const teslaTuple: T = ['tesla', 'model 3', 'model X', 'model Y'] as const // タプルであることを主張
初級3-Tuple to Object: 解説
完成形
type TupleToObject<T extends any[]> = {
[X in T[number]]: X
}
T[number]
: タプルT
に対するIndexed Access Types
- タプル型は配列型と異なり各要素の型が決まっているので、その型を返す
- 例: Tが
["a", 1, true]
→T[2]
はtrue
型、T[number]
は"a" | 1 | true
型 - 例: Tが
string[]
→T[100]
やT[number]
もstring
型
TupleToObject<["a", "b", "c"]> = {
[X in ["a", "b", "c"][number]]: X
}
=> {
[X in "a" | "b" | "c"]: X
}
=> { a: "a", b: "b", c: "c" }
初級4-First of Array: 問題
配列T
を受け取り、その最初のプロパティの型を返すFirst<T>
を実装します。
例えば:
type arr1 = ['a', 'b', 'c']
type arr2 = [3, 2, 1]
type head1 = First<arr1> // 'a'になる
type head2 = First<arr2> // 3になる
type head3 = First<[]> // neverになる
初級4-First of Array: 解説
惜しい回答
type First<T extends any[]> = T[0]
Indexed Access Typesを使って配列(またはタプル)の0番目の型を返せばいいはず
type head3 = First<[]> // never型であってほしいがundefined型になってしまう!
今回はnever
型が求められているのでnever
型を返したい
ちなみに実際にインデックス外にアクセスすると
const emptyArr: [] = [] as const
console.log(emptyArr[0]) // undefinedが出力される
初級4-First of Array: 解説
完成形
type First<T extends any[]> = T extends [] ? never : T[0]
T extends U ? X : Y
: Conditional Types
-
T extends U
の関係を満たすときはX
、さもなくばY
を返す型版の条件演算子 - 型引数
<...>
以外でextends
を使うときはほぼConditional Types
type R1 = 1 extends number ? "YES!" : "NO!" // "YES!"型になる
type R2 = "x" extends boolean ? true : false // false型になる
// T extends [] ? never : T[0] のTに[]型を渡した時
type R3 = [] extends [] ? never : [][0] // never型になる
初級5-Length of Tuple: 問題
タプルT
を受け取り、そのタプルの長さを返す型Length<T>
を実装します。
例えば:
type tesla = ['tesla', 'model 3', 'model X', 'model Y']
type spaceX = ['FALCON 9', 'FALCON HEAVY', 'DRAGON', 'STARSHIP', 'HUMAN SPACEFLIGHT']
type teslaLength = Length<tesla> // expected 4
type spaceXLength = Length<spaceX> // expected 5
初級5-Length of Tuple: 問題
完成形
type Length<T extends any[]> = T["length"]
T["length"]
: プロパティに対するIndexed Access Types
JSにおいて、配列オブジェクトのlength
プロパティは配列の要素数を返す。またプロパティに対するインデックス参照と.
によるアクセスは同義。
const arr = [1, 2, 3]
arr.length // 3になる
arr["length"] // インデックス参照と「.」によるアクセスは同義
そして、タプル型は長さが決まっているので、タプル型のlength
プロパティの型は数値リテラル型になる。
初級6-Exclude: 問題
組み込みの型ユーティリティExclude <T, U>
を使用せず、U
に割り当て可能な型をT
から除外する型を実装します。
例えば:
type Result = MyExclude<1 | 2 | 3 | 4, 1 | 3> // 2 | 4 になる
初級6-Exclude: 解説
完成形
type MyExclude<T, U> = T extends U ? never : T
Distributive Conditional Types(分配的なConditional Types)
例: MyExclude<T, U>
のT
にユニオン型1 | 2
を渡した時、以下のように分配される
(1 | 2) extends U ? never : T
=> (1 extends U ? never : 1) | (2 extends U ? never : 2)
これは数学における掛け算の分配法則と同じ
より正確にはT extends U ? X : Y
のT
にユニオン型が渡されたときに起こる。
初級6-Exclude: 解説
完成形
type MyExclude<T, U> = T extends U ? never : T
またnever
型とそれ以外の型とのユニオン型をとるとnever
型は消える性質がある
MyExclude<1 | 2 | 3, 2>
=> (1 extends 2 ? never : 1) | (2 extends 2 ? never : 2) | (3 extends 2 ? never : 3)
=> 1 | never | 3
=> 1 | 3
初級7-Awaited: 問題
Promise ライクな型が内包する型をどのように取得すればよいでしょうか。
例えば:Promise<ExampleType>
という型がある場合、どのようにして ExampleType を取得すればよいでしょうか。
type R1 = MyAwaited<Promise<string>> // string
type R2 = MyAwaited<Promise<{ field: number }>> // { field: number }
type R3 = MyAwaited<Promise<Promise<string | number>>> // string | number
type R4 = MyAwaited<Promise<Promise<Promise<string | boolean>>>> // string | boolean
type Err = MyAwaited<string> // Promiseではないのでエラー
初級7-Awaited: 解説
惜しい実装
type MyAwaited<T extends Promise<any>> = T extends Promise<infer U> ? U : never;
infer U
: 型推論
- Conditional Types(
X extends Y ? L : R
)のY
以降に使えるキーワード -
X extends Y<infer Z>
においてX extends Y<any>
を満たすとき、any
の部分にマッチする型を型変数Z
に代入し、L
の部分で使えるようにする。
MyAwaited<Promise<string>>
=> Promise<string> extends Promise<infer U /* stringに推論 */> ? U : never
=> string
これだけだと以下のようにネストされたPromiseに対応できない。
MyAwaited<Promise<Promise<string>>>
=> Promise<Promise<string>> extends Promise<infer U> ? U : never
=> Promise<string>
初級7-Awaited: 解説
推論されたU
がPromise<any>
か検証し、そうであれば再帰呼び出しすることで解決
type MyAwaited<T extends Promise<any>>
= T extends Promise<infer U>
? U extends Promise<any>
? MyAwaited<U>
: U
: never;
例
MyAwaited<Promise<Promise<string>>>
=> Promise<Promise<string>> extends Promise<infer U /* Promise<string> に推論 */ >
? Promise<string> extends Promise<any>
? MyAwaited<Promise<string>>
: Promise<string>
: never;
=> MyAwaited<Promise<string>>
=> Promise<string> extends Promise<infer U /* string に推論 */ >
? string extends Promise<any>
? MyAwaited<string>
: string
: never;
=> string
初級8-If: 問題
条件値C
、 C
が truthy である場合の戻り値の型T
、C
が falsy である場合の戻り値の型F
を受け取るIf
を実装します。
条件値C
はtrue
かfalse
のどちらかであることが期待されますが、T
と F
は任意の型をとることができます。
例えば:
type A = If<true, 'a', 'b'>; // expected to be 'a'
type B = If<false, 'a', 'b'>; // expected to be 'b'
初級8-If: 解説
完成形
type If<C extends boolean, T, F> = C extends true ? T : F
急にすごい簡単になった...
ちなみにboolean
はtrue | false
と等価
初級9-Concat: 問題
JavaScript のArray.concat
関数を型システムに実装します。この型は 2 つの引数を受け取り、受け取ったイテレータの要素を順に含む新しい配列を返します。
例えば:
type Result = Concat<[1], [2]>; // expected to be [1, 2]
初級9-Concat: 解説
完成形
完全知識問題
type Concat<T extends any[], U extends any[]> = [...T, ...U]
[...T]
: Variadic Tuple Types(可変長タプル型)
-
T
がタプル型であるとき、T
を一部分に展開した新たなタプル型を返す
type T1 = ["a", "b", "c"]
type T2 = ["start", ...T1, "end"] // ["start", "a", "b", "c", "end"]になる
初級10-Includes: 問題
JavaScriptのArray.include
関数を型システムに実装します。この型は、2 つの引数を受け取り、true
やfalse
を出力しなければなりません。
例えば:
type isPillarMen = Includes<['Kars', 'Esidisi', 'Wamuu', 'Santana'], 'Dio'> // expected to be `false`
ただし、Equal<T, U>
は使ってもよいです。
Equal<1, 1> // true
Equal<1, "a"> // false
初級10-Includes: 解説
素朴にわかる範囲まで実装
type Includes<T extends any[], U> = /* 後で考える */ ? true : false
理想的にはfor文のようにT
から要素を1つづつ取り出してU
と比較し、一致していればtrue
を返したい。だがfor文は無いので再帰を考える。
-
T
が[]
ならfalse
を返す -
T
の先頭の要素の型とU
が等しいかを比較する- 等しいなら
true
を返す
- 等しいなら
- 等しくないなら
T
から先頭の要素を取り除いたタプルR
を用意する -
Includes<R, U>
を返す
// 擬似コード
includes([1, 2, 3], 2)
=> 1 != 2 なので return includes([2, 3], 2)
=> 2 == 2 なので return true
初級10-Includes: 解説
再帰ステップの1と2の途中まで実装
タプルT
から先頭H
と残りR
の型を取り出し、空ならfalse
を返すところまで実装
type Includes<T extends any[], U> = T extends [infer H, ...infer R]
? /* あとで考える */
: false;
T extends [infer H, ...infer R] ? X : Y
- Conditional TypesとVariadic Tuple Typesと型推論の組み合わせ(TS4.0の新機能)
-
T
がタプル型のとき、H
にはT
の先頭要素の型に、R
にはT
から先頭を取り除いたタプル型が入る - 例:
[1, 2, 3] extends [infer H, ...infer R]
→H
は1
、R
は[2, 3]
- 例:
[1] extends [infer H, ...infer R]
→H
は1
、R
は[]
- 例:
[] extends [infer H, ...infer R]
→ そもそも成り立たず、Y
が呼ばれる
初級10-Includes: 解説
タプルT
から先頭H
とそれ以外R
を取り出せた
type Includes<T extends any[], U> = T extends [infer H, ...infer R]
? /* あとで考える */
: false;
完成形
あとはH
とU
を比較し、等しければtrue
を返し、さもなくばIncludes<R, U>
を返せばよい
type Includes<T extends any[], U>
= T extends [infer H, ...infer R]
? Equal<H, U> extends true
? true
: Includes<R, U>
: false;
初級10-Includes: 解説
完成形
type Includes<T extends any[], U>
= T extends [infer H, ...infer R]
? Equal<H, U> extends true
? true
: Includes<R, U>
: false;
T
が[1, 2, 3]
、U
が2のとき
例:Includes<[1, 2, 3], 2>
=> [1, 2, 3] extends [infer H /* 1 */, ...infer R /* [2, 3] */]
? Equal<1, 2> extends true
? true
: Includes<[2, 3], 2>
: false;
=> Includes<[2, 3], 2>
=> [2, 3] extends [infer H /* 2 */, ...infer R /* [3] */]
? Equal<2, 2> extends true
? true
: Includes<[3], 2>
: false;
=> true
初級11-Push: 問題
Array.push
のジェネリックバージョンを実装します。
例えば:
type Result = Push<[1, 2], boolean> // [1, 2, boolean]
初級11-Push: 解説
完成形
Variadic Tuple Typesで一撃
type Push<T extends any[], U> = [...T, U]
初級12-Unshift: 問題
Array.unshift
の型バージョンを実装します。
例えば:
type Result = Unshift<[1, 2], 0> // [0, 1, 2]
初級12-Unshift: 解説
完成形
Variadic Tuple Typesで(ry
type Unshift<T extends any[], U> = [U, ...T]
初級13-Parameters: 問題
組み込みの型ユーティリティParameters<T>
を使用せず、T
からタプル型を構築する型を実装します。
例えば:
type F = (arg1: string, arg2: number) => void // 関数の型
// 関数の型の使用例
function f(arg1: string, arg2: number) {
console.log("foo")
}
const f1: F = f
// 関数の型のアロー関数での使用例
const f: F = (arg1: string, arg2: number): void => {
console.log("foo")
}
type FunctionParamsType = MyParameters<F> // [string, number]になる
初級13-Parameters: 解説
完成形
infer
は関数の型に対しても使える
type MyParameters<T extends (...args: any[]) => any>
= T extends (...args: infer U) => any
? U
: never;
感想
- 「ここにこれ書けるの!?」の驚きの連続だった
- 型に対する自分の中の固定観念を取っ払うことができた
- その後中級の途中までやってみたが独特の制約の中で再帰処理を書くのが楽しくなった
- 是非続きをやってみてほしい
- 解説していないこと
-
never
型とany
型の関係 -
extends
とEqual<T, U>
の違い -
Equal<T, U>
の実装 - などなど...
-
Discussion