実例 RecursivePartial<T> / TypeScript一人カレンダー
こんにちは、クレスウェア株式会社の奥野賢太郎 (@okunokentaro) です。本記事はTypeScript 一人 Advent Calendar 2022の13日目です。昨日は『Partial<T>
』を紹介しました。
Partial<T>
を活用する瞬間
昨日の記事で、筆者はPartial<T>
を積極的には使わないと述べました。そんな中、Partial<T>
を使わざるを得ない状況というのがありました。それがGraphQLを扱う状況です。
GraphQLを扱う案件では、そのアーキテクチャやGraphQL Resolverの実装者・実装チームが自分たちのチームと近いかどうかによって判断が大きく異なってくると思います。一気通貫でGraphQLも面倒を見られる場合は、TypeScriptとの親和性を優先して型定義ファイルの生成などを柔軟に行えると思います。ところが筆者の関わった案件の一つでは、GraphQLのスキーマが外部から用意され、その定義からTypeScriptの型定義ファイルを自動生成したとしてもあまり恩恵があるといえる状況ではなかったため、やむなく自作する判断を取りました。
自作は手間である一方、その案件に必要なユースケースのみを定義すれば満足できるため、使わない大量のクエリやプロパティが混入して読みづらいといった状況を回避でき、依存を切り離して柔軟に型を定義できるため、デメリットばかりではありません。
ただ、そこで問題になったのが値なしの扱いでした。相手方のGraphQLサーバーはJava由来だったため、すべての値なしをnull
として送出してきます。ところが、オプショナルなプロパティについては、プロパティ自体が存在しない場合ECMAScriptにおけるundefined
として送出してきます。そしてもう一つ厄介なのが「こちら側がgqlのクエリを書き損じた場合」もundefined
を得てしまうという問題の存在でした。まとめると、null
だったりundefined
だったりすることがあるが、その値になる理由は相手側によるもの、自分側によるものと様々である、という状況でした。
こういう状況では、さっさとunknown
を採用して何も信じない世界にしてもよいものですが、当時その限られたGraqhQL資源を活用しようとTypeScriptの型定義を作成したばかりであったため、そういったプロパティ名補完の恩恵は受けておきたいというチーム内判断になりました。
そこで活躍したのがPartial<T>
です。Partial<T>
はすべてのプロパティを一斉にOptional扱いにしてしまうため、場合によっては大雑把になりすぎてしまいOmit<T, K>
を使った方が安全さの精度はあがるとしました。ですが今回は不確実性に溢れていたのでPartial<T>
が手頃となります。
RecursivePartial<T>
Partial<T>
は状況によっては便利ですが、欠点がありました。すべてのプロパティをOptionalにするといっても、再帰しないため深くネストした型定義であればその都度指定していく必要があります。GraphQLの型定義は、その特性上ネストが深くなりがちであるためPartial<T>
を何度も記述して可読性が下がるという事態になりました。そこで定義したのがRecursivePartial<T>
です。
type RecursivePartial<T> = {
[P in keyof T]?: T[P] extends (infer U)[]
? RecursivePartial<U>[]
: T[P] extends object
? RecursivePartial<T[P]>
: T[P];
};
type T11 = RecursivePartial<{
a: { a1: string; a2: number };
b: number;
c: Array<{ c1: boolean }>;
}>;
この辺りから筆者の友人のエンジニア数名は拒絶反応を起こすようになりました。たしかに6行のConditional Typesとなれば、今までこのアドベントカレンダーで紹介してきた自作のUtility Typesの中でも最長です。ですが、これは三項演算子の分岐と関数の再帰であると考えればECMAScriptでの処理と大きく異るわけではありません。
なにをやっているか
もう少し確実に定義の中でやっていることをみていきましょう。
[P in keyof T]?:
は、Mapped Types構文です。T
型に渡されたオブジェクト型のプロパティをすべて列挙し、その都度P
型パラメータとして扱います。?:
はMapping Modifiers構文です。繰り返し処理されるプロパティに対して、すべてにOptional修飾子?
を指定していく操作です。
以後の三項演算子のネストが見る者を圧倒させてしまう部分です。シンプルに書くとこれはA ? B : C ? D : E
という構造をしています。ECMAScriptであればif
文を使ってもよさそうな場面ですがTypeScriptのConditional Typesは三項演算子でしか記述できないため、こういった箇所は複雑になりがちです。
ここは噛み砕くと「T[P]
型は配列型か?オブジェクト型か?それ以外か?」と問うている状況です。そしてそれぞれの状況に応じて結果が分岐しています。配列型であれば、配列を構成する型Array<U>
におけるU
をRecursivePartial<U>
になるよう指定し、その結果をRecursivePartial<U>[]
として配列に戻しています。infer
は以前紹介したものです。
オブジェクト型であるかどうかはobject
型で判定します。object
自体は後日紹介しますが、ここではオブジェクト型かどうかの判定に使う型であると捉えてください。オブジェクト型であれば、RecursivePartial<T[P]>
として再帰処理にかけられます。
配列型でもオブジェクト型でもなかった場合はT[P]
が返ります。ここはPartial<T>
と全く同じです。
再帰を活用しよう
このようにTypeScriptのConditional Typesでは再帰を扱うことができます。似たような例としてAwaited<T>の実装を紹介した際も、その記事では詳細には解説しませんでしたがさりげなく再帰が使われています。
再帰を扱えるということは、関数型プログラミングの思考はTypeScriptを活用していく際に有用です。筆者の場合、関数型プログラミングのアカデミックな教育は受けていませんが、幸い自分がまだプロキャリアに就きたてだった頃に書籍『すごいHaskellたのしく学ぼう!』を読んでいたこともあってTypeScriptに対して考え方をかなり活かすことができ、これは後のキャリアにとって大きな利益となりました。
TypeScriptは他の言語、Haskell, Rust, Scalaのような型システムとは異なる点が多く、逆に型熟練者であるほどTypeScript特有の不便さが目についてしまうこともあるかもしれませんが、一方で初学者や中級者でTypeScriptのさらなる活用を目指すのであれば、関数型プログラミングや他の言語の型システムを学んでみるというのも周辺の視野を広げる観点で有用だと思います。
Required<T>
, Readonly<T>
』
明日は『実例紹介でもMapped TypesとConditional Typesが合わさるようになり、TypeScriptらしさが増してきました。既存のUtility Typesのちょっと不便な点というのは、工夫次第で乗り越えていけます。
明日もまだまだMapped Typesを紹介していきます。それではまた。
Discussion
こんにちは
RecursivePartial
についてですが、別のプロジェクトで同じような処理になっているところで(最近のTypeScriptだからか)バグがあって、issue投げて修正されたのですが、参考になれば幸いです。