💭

TypeScript type challengesを解いてみた

2023/10/21に公開

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


流れ

  1. TypeScriptの成り立ち
  2. TypeScriptの型の基本
  3. TypeScript type challenges 初級編を解く
    1. ユーティリティ型の解説
    2. 解答解説

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

型引数Tobject型であり、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)

これは数学における掛け算の分配法則と同じ

(1 + 2) \times u = (1 \times u) + (2 \times u)

より正確にはT extends U ? X : YTにユニオン型が渡されたときに起こる。


初級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: 解説

推論されたUPromise<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: 問題

条件値CCが truthy である場合の戻り値の型TCが falsy である場合の戻り値の型Fを受け取るIfを実装します。
条件値Ctruefalseのどちらかであることが期待されますが、TF は任意の型をとることができます。

例えば:

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

急にすごい簡単になった...

ちなみにbooleantrue | 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 つの引数を受け取り、truefalseを出力しなければなりません。

例えば:

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文は無いので再帰を考える。

  1. T[]ならfalseを返す
  2. Tの先頭の要素の型とUが等しいかを比較する
    • 等しいならtrueを返す
  3. 等しくないならTから先頭の要素を取り除いたタプルRを用意する
  4. 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]H1R[2, 3]
  • 例: [1] extends [infer H, ...infer R]H1R[]
  • 例: [] extends [infer H, ...infer R] → そもそも成り立たず、Yが呼ばれる

初級10-Includes: 解説

タプルTから先頭Hとそれ以外Rを取り出せた

type Includes<T extends any[], U> = T extends [infer H, ...infer R]
  ? /* あとで考える */
  : false;

完成形

あとはHUを比較し、等しければ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型の関係
    • extendsEqual<T, U>の違い
    • Equal<T, U>の実装
    • などなど...

Discussion