🦛

type-challenges【初級】でTypeScriptを学び直す

に公開

概要

TypeScriptの理解を向上させるために、type-challengesの初級を解いてみる事にしました。type-challengesとはTypeScriptの型を初級者から上級者まで与えられた「課題」を解決するクイズ形式で学ぶことができるOSSプロジェクトです。既に多くの先人たちが取り組んでおり、素晴らしい解説記事が多く公開されていますが、実際に自分で手を動かして解いてみると、既存の解説を読むだけでは得られない気づきや学びが多くありました。そこで本記事では、私自身が初級問題に取り組む中で得た学びを改めて整理していきたいと思います。


事前知識

初級とはいえ、なんの知識もなく挑むと普通に挫折しますので、取り組む時に必要なTypeScriptの知識や考え方から解説していきます。

<T, K extends keyof T>

<T, K extends keyof T>について、ここのextendsは継承ではなく、制約の意味を指します。

例えば以下のような場合、Textends objectを追加することで、「オブジェクトだけを受け取る」といった制約をかけることができます。また同様に、K extends keyof Tで「key は obj のキーのどれか」に制約をかけます。

const getProps<T extends object, K extends keyof T> = (obj:T, key: K):T[K]  => {
 return obj[key];
}
const user = { id: 1, name: "Alice" };

const id = getProp(user, "id");    // number
const name = getProp(user, "name"); // string

https://zenn.dev/yskn_sid25/articles/1b0b48d0a15426#length-of-tuple

https://zenn.dev/yskn_sid25/articles/da0547f3128308?redirected=1


Mapped types

Mapped typesは、{ [ P in K ] : T }の表記で型を新しく動的に作り出すことができます。PはキーでKはプロパティ名の Union 型("id" | "name" | "email" など)を指し、Tは生成される型の値部分になります。

https://www.typescriptlang.org/docs/handbook/2/mapped-types.html

type T1 = { [P in "x" | "y"]: number };  // { x: number, y: number }

動的に型が生成されており、Kに対して反復処理を行うことで、それぞれのプロパティを持つオブジェクト型に変換されます。

さらに、keyofを組み合わせることで型からプロパティ名(Key)を取り出して、inにより反復処理を行うことで、型の変換を行うことが可能です。

type Item = { a: string, b: number, c: boolean };

type T1 = { [P in keyof Item]: Date };  // { a: Date, b: Date, c: Date }
type T2 = { [P in keyof Item]: Item[P] };  // { a: string, b: number, c: boolean }
type T3 = { readonly [P in keyof Item]: Item[P] };  // { readonly a: string, readonly b: number, readonly c: boolean }
type T4 = { [P in keyof Item]: Array<Item[P]> };  // { a: string[], b: number[], c: boolean[] }


Lookup Types

Item[P]のような記述はLookup Typesと呼びオブジェクトの型の特定のプロパティの型を取り出す仕組みです。

https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-1.html

type User = {
  id: number;
  name: string;
  email: string;
};

type UserIdType = User["id"];
// number

さらにこの、Lookup Typesをkeyofと組み合わせることが可能です。

type ValueOfUser = User[keyof User]; // number | string

https://github.com/Microsoft/TypeScript/pull/12114

このMapped typesを使用したPartialReadonlyといったユーティリティ型は、lib.d.tsで定義されています。

// Make all properties in T optional
type Partial<T> = {
    [P in keyof T]?: T[P];
};

// Make all properties in T readonly
type Readonly<T> = {
    readonly [P in keyof T]: T[P];
};


Pick

組み込みの型ユーティリティであるPickを使用することなく、TからKのプロパティを抽出する型を自作します。

interface Todo {
  title: string
  description: string
  completed: boolean
}

type TodoPreview = MyPick<Todo, 'title' | 'completed'>

const todo: TodoPreview = {
    title: 'Clean room',
    completed: false,
}

回答

K extends keyof TでK は「T のプロパティ名のどれか」に限定されるので、Tに存在しないキーを指定するとコンパイルエラーになります。[Key in K]: T[Key]はMapped TypeでKに含まれるKeyを一つづつ取り出し、動的なオブジェクトを生成します。

type MyPick<T, K extends keyof T> = {
  [Key in K]: T[Key];
}



Readonly

組み込みの型ユーティリティReadonlyを使用することなく、Tのすべてのプロパティを読み取り専用にする型を実装します。

interface 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

回答

type MyReadonly<T> = {
  readonly [key in keyof T]: T[key]
}

Mapped typesの[key in T]keyofを組み合わせることで型からプロパティ名(Key)を取り出して、inにより反復処理を行うことで、型の変換を行います。readonly修飾子を先頭に付け、プロパティを読み取り専用にします。


Tuple to Object

タプルを受け取り、その各値のkey/valueを持つオブジェクトの型に変換する型を実装します。

const tuple = ['tesla', 'model 3', 'model X', 'model Y'] as const
type result = TupleToObject<typeof tuple> // expected { 'tesla': 'tesla', 'model 3': 'model 3', 'model X': 'model X', 'model Y': 'model Y'}

回答

type TupleToObject<T extends readonly PropertyKey[]> = {
  [K in T[number]]: K
}

Mapped typesを使用し、[K in T[number]]: Kの表記でオブジェクトを形成します。これまでと違う点はT[number]の記述です。これはTに対してすべての要素の型を取得し、union型として結合するTypeScriptの機能です。

type Colors = ['red', 'green', 'blue'];
type Color = Colors[number]; // 'red' | 'green' | 'blue'

const arr = ['a', 'b', 'c']
type A = typeof arr[number] // string

タプル型をnumberでunion型として結合される理由は0 | 1 | 2 | 3 | ...を抽象化するためであり、以下の記事で詳細が記載されています。

https://zenn.dev/luvmini511/articles/d89b3ad241e544

T[number]でタプルTの要素をunionで取り出し、Mapped typesでKをunionの各要素で回して、そのKをキーにして、値もKにすることでその各値のkey/valueを持つオブジェクトの型に変換できます。


PropertyKeyはTypeScriptに組み込みで用意されているオブジェクトのキーとして使える型の総称です。オブジェクトのキーとして使える型はstring | number | symbolの3種類しかなく、これ以外のboolean,objectはキーになりません。extends readonly ...[]PropertyKeyを組み合わせることで、オブジェクトのキーにできる値だけが入った readonlyなタプルを受け取るものとして表現できます。

https://zenn.dev/hyopt/articles/6b8fd769cd708a


First of Array

配列Tを受け取り、その最初のプロパティの型を返すFirst<T>を実装します。

type arr1 = ['a', 'b', 'c']
type arr2 = [3, 2, 1]

type head1 = First<arr1> // expected to be 'a'
type head2 = First<arr2> // expected to be 3

回答

type First<T extends any[]> = T extends [] ? never : T[0];

T extends [] ? never : T[0]Conditional Typesです。Conditional Types、型の条件分岐、条件型を記述することができ、三項演算子のように、T extends U ? X : Yで記述します。配列の要素の型を参照する場合は、T[number]を使用しますが、今回は最初のプロパティを返却する必要がある為、ブラケット記法に数値リテラル型T[0]を記述します。これで空配列[]の場合はneverを返し、それ以外をT[0]で配列の最初の要素を返却することが可能です。

別解として、inferを使用した記述も可能です。

type First<T extends any[]> = T extends [infer F, ...any[]] ? F : never;

inferは先ほどの型演算子(Conditional Types)内で使用され、extendsの右辺にのみ書くことができます。ジェネリクス型のTが配列の型とし、その配列の先頭の要素をFとして推論します。
先頭の要素以外(2と3)は...any[]となって、1のみがFとして推論されるため、結果とFirstは最初の要素返却します。

https://tech-blog.rakus.co.jp/entry/20240125/typescript


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

回答

type Length<T extends readonly any[]> = T['length']

readonlyなしのtype Length<T extends any[]>とした場合、as constで宣言されたタプル(例: ['tesla', 'model 3'] as const)はreadonlyな型として推論されるため、any[]には割り当てられません。そのため、readonly any[]とすることで、as constタプルも受け入れられるようにするため、readonlyを付与します。また、T['length']を記載することで配列Tからlengthプロパティの長さを取り出すことが可能です。


Exclude

組み込みの型ユーティリティExclude <T, U>を使用せず、Uに割り当て可能な型をTから除外する型を実装します。

type Result = MyExclude<'a' | 'b' | 'c', 'a'> // 'b' | 'c'

回答

type MyExclude<T, U> = T extends U ? never : T

Conditional Typesによる型の条件分岐を使用して、Tが排除したいUであれば、neverを返却しユニオン型から自動的に消えるというTypeScriptの性質を利用します。


Awaited

Promiseの解決値の型Tを取得するユーティリティ型Awaited<T>を実装します。

type ExampleType = Promise<string>
type Result = MyAwaited<ExampleType> // string

回答

type MyAwaited<T> = T extends PromiseLike<infer U> 
  ? U extends PromiseLike<any>
    ? MyAwaited<U>  // 再帰的に解決
    : U
  : T

First of Arrayで登場したinferを使用しPromiseLikeの中身の型Uを取得します。
Uのみ定義した場合、Uはどこにも宣言されていないので、TypeScriptは認識することができないため、ここの型を推論するinferを使用します。PromiseLike<T>はTypeScriptの組み込み型で、Promiseのような振る舞いをする型を表します。ユーティリティ型Awaited<T>の場合、catch, finallyなどのメソッドを持ちますが、PromiseLike<T>Awaited<T>で紹介されている実装の{ then(onfulfilled: infer F, ...args: infer _): any }と同じようにthenメソッドを持つオブジェクトを検出しています。

interface PromiseLike<T> {
  then(
    onfulfilled?: (value: T) => any,
    onrejected?: (reason: any) => any
  ): PromiseLike<any>
}

Promiseの型でなければ、そのままTを返却します。Promiseの場合は、Awaited<Promise<string>>;のようにPromiseがネストしている可能性があるため、MyAwaitedを再帰的に呼び出し、最終的な解決値の型を取得できるようにします。


If

第一引数がtrueである場合の戻り値の型T、第二引数がfalseである場合の戻り値の型Fを受け取るIfを実装します。

type A = If<true, 'a', 'b'>; // expected to be 'a'
type B = If<false, 'a', 'b'>; // expected to be 'b'

回答

type If<C extends boolean, T, F> = C extends true ? T : F

C extends trueでtrueの場合はTを返し、そうでない場合はFを返却することで実装できます。Cはtrueまたはfalseが渡される為、booleanに制約しておきます。



Concat

Array.concat関数を型システムとして実装します。2つの引数を受け取った後、受け取った順番の通り配列として返却します。

type Result = Concat<[1], [2]>; // expected to be [1, 2]

回答

type Concat<T extends readonly any[], U extends readonly any[]> = [...T, ...U]

TUはタプル型、配列型、as constを受け入れる制約を追加し、配列を結合します。自分は知らなかったのですが、JavaScriptのスプレット構文が型の世界にも存在(Variadic Tuple Types)しており、[...T, ...U]とすることでタプル型のスプレッド構文で2つのタプルを結合できます。

https://qiita.com/uhyo/items/7e31bbd93a80ce9cec84


Includes

Array.include関数を型システムとして実装します。2つの引数を受け取り、trueまたはfalseを出力します。

type isPillarMen = Includes<['Kars', 'Esidisi', 'Wamuu', 'Santana'], 'Dio'> // expected to be `false`

回答

最初は単純にextendsを使用して制約をかけることで、実装可能かと思いました。ただこれだとUが配列全体Tのサブタイプかどうかの判定になってしまうので、Uが配列Tの要素の中に含まれるかの条件に合致しません。

type Includes<T extends readonly any[], U> = U extends T ? true : false

今回の場合は、先頭要素と残りの要素を抽出し一致しなければ、残りの配列を再帰的にチェックしていく方式を取る必要があります。

type Includes<T extends readonly any[], U> = 
  T extends [infer First, ...infer Rest] 
    ? Equal<U, First> extends true
      ? true 
      : Includes<Rest, U>
    : false

複雑な型になりますが、順を追って確認していきます。まずはじめに、Concatで実装したVariadic Tuple Typesを利用します。Variadic Tuple Typesは端的に言うとタプル型の中に...Tと書ける機能ですが、そこに固定長のタプルと可変長の要素を組み合わせることで先頭要素とその他で分割することが可能です。JavaScriptの配列分割代入のようなイメージですかね。ここでは[infer First, ...infer Rest]で先頭要素Firstと残りRestでタプルに分解させる構成となっています。FirstとRestという変数が存在しないので、後続の処理で使用するために、inferを使用します。最初の値が引数Uと等価であるかどうかはEqual<X,Y>型が用意されているためそちらを使用します。Equal<X,Y>については以下の記事で詳しく記載されています。

https://zenn.dev/yumemi_inc/articles/ff981be751d26c#tl%3Bdr

等価でなければIncludesを再度呼び出し残りの配列を再帰的にチェックしていくことでtrueまたはfalseを出力します。


Push

Array.pushを自作します。

type Result = Push<[1, 2], '3'> // [1, 2, '3']

回答

type Push<T extends readonly unknown[], U extends unknown> = [...T, U]

extendsによる型の制約はanyよりもunknownの方がより型安全のため、extends unknownとし、Variadic Tuple Typesを利用して[...T, U]タプルを展開して、末尾にUを追加します。


Parameters

Parametersジェネリックを自作します。

const foo = (arg1: string, arg2: number): void => {}
type FunctionParamsType = MyParameters<typeof foo> // [arg1: string, arg2: number]

回答

type MyParameters<T extends (...args: any[]) => any> = 
  T extends (...args: infer P) => any ? P : never

Parametersジェネリックとは、関数の型Tを渡した場合そのパラメータをタプルの型で返すユーティリティ型の一つです。Conditional Typesを使用して関数型かどうかを判定(関数型は(...args: any[]) => anyのような形で表現)し関数であれば、infer Pで引数の型をPとして取り出します。関数でない場合は、到達不可能な分岐としてneverとします。


最後に

最初は一問解くのに苦労しましたが、問題を解いていく毎にコツを掴むことができました。また時間ができたら中級問題にチャレンジしていきたいと思います。


Discussion