🐈

type-challengesで型パズルを解きまくってTypeScript筋を鍛える

2022/05/26に公開1

どうも、株式会社プラハCEOの松原です

この記事は「そもそもTSの型をどうやって勉強したら良いの?公式読んでも一向に覚えられないんだけど...難しいし...よく分からないから書きたくない...」とお考えの、型が嫌いな方々に向けた記事です。

type-challengesを使えば遊び感覚で練習できるよ!という楽しさを伝えつつ、最終的には「うるせぇ!!!かこう!!!(ドンッ!!!)」って方向に持ってくのが目的です(ワンピース読んでない人には意味のわからない話で申し訳ない)

type-challengesとは

https://github.com/type-challenges/type-challenges

初級・中級・上級・鬼(意訳)に分類された様々な型パズルが用意されているので、1日1問解いていけばあら不思議、1ヶ月も経てば好きな型をささっと組み立てられるTSマッチョに。そんなTS筋を鍛えるトレーニングに最適です。

習うより慣れろ

確かにTSの公式は情報としては充実しているのですが、お世辞にも初心者向けとはいえません。かつTSは機能も豊富なので、公式ドキュメントを上から下まで全て読んで理解するのは非常に大変です。

個人的には習うより慣れろの精神で、type-challengesで課題に沿って型を作りまくったほうが「解けた!」という喜びもあるから続けやすいし、自分が解いた課題と似たような状況に業務で直面したら「あ、これ 進研ゼミ type-challengesで見たやつだ!」とすぐ活かせるので記憶に定着しやすい気がするんですよね。

というわけで早速、どんな問題が用意されているのか見てみましょう。

初級問題:read-only

問題:与えられた型のプロパティを全てreadonly(更新不可)にする型を作りましょう

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

これが問題文で、MyReadonlyは現状定義されていないので、この型を定義してあげる必要があります。TS Playgroundあたりを使って実際に書いてみると、こんな感じでしょうか:

回答

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

K in keyof Tmapped-typeと呼ばれるヤツで、イメージとしては...

渡された型パラメータTの全key(todoの場合はtitleとdescription)を走査しつつ変数Kに格納するforEach処理

みたいな感じです。K = title, K = descriptionのループが2回動いているような感じ。

なのでK(title,description)というキーに対してT[K](string,string)をマッピングして、Kにreadonlyを付与することで全てのプロパティがreadonlyになる...的な流れです。頑張って説明してみたのですが型の説明めちゃくちゃ難しい...説明が下手でごめんなさい

どうしても詰まったら他の方の回答も見て勉強できますし、自分の回答と比較するのも楽しいです。

例えば今回の回答者は<T extends object>と書いているのが僕の回答との相違点です。

こうすることでnullやundefinedを渡される心配がなくなるので(MyReadonly<null>MyReadonly<undefined>と書けなくなる)、意図しない使い方を防止しているこちらの書き方が良さそうだな〜、とか。

objectじゃなくて<T extends {}>でも似た挙動になりそうだけど、これだとMyReadonly<string>みたいなプリミティブが許容されるな〜、みたいなことを考えていると勉強になります。

また、「じゃあextendsを何も書かなかった時は何をextendしてるんだろう」と気になって調べていくと、TS3.5以降型パラメータがデフォルトで<T extends unknown>になったためnullもundefinedも指定できるようになった、みたいな経緯にたどり着いて、公式に書かれている内容の理解が深まることもあるのではないでしょうか。

初級問題は結構応用が効く

初級問題は結構応用が効きやすいのでオススメです。例えばプロパティを全部optionalにする型とか:

type MyOptional<T> = {
  [K in keyof T]?: T[K] // ?を加えることでoptionalになる
}

プロパティを全部optional非許容にする型とか:

type MyNonoptinal<T> = {
  [K in keyof T]-?: T[K] // -?を加えることでoptionalをrequiredに変える
}

どちらも先程の初級問題の応用ですね。パターンが自分の中に蓄積されると作業が相当速くなるので、少なくとも初級問題は何度か反復するのがオススメです!

初級問題:Parametersを自作する

中にはTS Playgroundでテストケースまで書いてくれている丁寧な出題者も!ExpectやEqualなどアサーション関連のメソッドも型で表現されていて凄い!

今回は元々TSに用意されているユーティリティ型の一つ「Parameters」を自作する課題です。関数から引数の型を抽出するので、こんな感じでしょうか。

回答

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

MyParametersに渡された型Tがもし(args) => /*何か*/の形になっていれば、args(引数)の型の配列をAに代入して、Aを返す。もしTが上記のシグネチャに一致しなければneverを返す...的な流れです。

既に用意されている型から新たな型を作り直すときによく見かけるinferについて学べる課題ですね!

他の方の回答を見てみると、自分は型を書いてるうちにコードが横に伸びてきて読みづらくなったのでMyParameters<T extends (...args: any[]) => any>のextendsを消したまま戻し忘れていたのですが、これだとMyParameters<number>のように関数以外の型も渡せてしまうのでいけませんね...neverが返るとはいえ事前に防ぐに越したことはないので。

中級問題:remove index signature

index signatureは事前にプロパティ名が全て判明していない時に使われることがありますが、例えば以下のようなindex signatureだとインデックスを使って参照した値がanyになってしまうデメリットがあります。

type AnyDictionary = {
    [key: string]: any;
}

const d: AnyDictionary = {hoge: 3}
d.hoge // any型になっている。実際は3
d.fuga // any型になっている。実際はundefined

自分たちで定義するときはこうした型は避けるに越したことはないのですが、外部ライブラリで既に定義されている時にindex signatureを取り除いておきたいことがあります。そんな課題ですね。解いてみるとこんな感じでしょうか:

回答

type RemoveIndexSignature<T> = {
  [K in keyof T as string extends K ? never : K]: T[K]
}

mapped-typeのキーをasで拡張できる機能(TS4.1以降)を使います。基本的な使い方のおさらいとして、[K in keyof T as K2]と定義することでKをK2として再定義します。これだけだと何も実用性はないのですが、TSの公式にはテンプレートリテラルと組み合わせることでgetterを定義するような例が見つかります:

type Getters<Type> = {
    [Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]
};

こんな具合にPropertyを拡張できるので、これを応用します。

もう一つ必要な前提知識があります。[K in keyof T as never]と書けば、渡された型から全てのプロパティを削除した型を作れることです。

  • [K in keyof T as never]と書けばプロパティが消えて
  • [K in keyof T as K]と書けばプロパティが残る

後はこれを条件に応じて切り替えれば、条件に応じてプロパティを削除できそうなのですが、果たしてindex signatureなのか定義済みのプロパティ名('foo')なのか、どう判断すれば良いのでしょうか。今回は以下のようなロジックを用います:

  • string extends string // true -> never
  • string extends 'foo' // false -> K

stringはstringですが、stringは'foo'とは限りませんよね。この条件を用いることで、名前が判明しているプロパティのときはKが返り、index signatureの時はneverが返るようになる...という分岐が書けます。

string extends K ? never : K

これで回答に戻ってきました。

type RemoveIndexSignature<T> = {
  [K in keyof T as string extends K ? never : K]: T[K]
  // fooの時 -> K in keyof T as K -> 残る
  // index signatureの時 -> K in keyof T as never -> 消える
}

ただ、実は今回の回答は不完全でして...

type Foo = {
  [key: string]: any;
  [key: number]: any; // こいつを排除できない
  foo(): void;
}

index signatureはstringの他にnumberも使えるのですが、今回はstringしか想定していないため、numberが使われていると string extends number -> falseとなり、index signatureが削除できません。

これを解決する方法は自力ではたどり着けなかったので他の回答を見て学びました。型ってムズカシイ...!

こんな感じ!

少しだけ型パズルの楽しさにハマり始めた型は、ぜひtype-challengesで遊んでみては如何でしょうか?extreme問題まで用意されているので頑張ってください!

そもそもなんで型に強くなる必要があるの?

実行時例外を減らせる、つまり動かす前に実装ミスを見つけて潰せるからです。

型がないと実際にサービスを動かしてみるまで本当に動くか分からないので「やってみなきゃわかんねぇだろ!(ドンッ!!!!!)」と、開発工程が少年漫画みたいなノリになります。

やってみなきゃ分かんないモードで開発すると熱い展開(炎上)が待っているので、ちゃんと型で回避しような!

アガルートテクノロジーズ/PrAha

Discussion