こんにちは。LINE証券フロントエンドエンジニアの鈴木です。この記事は「UIT 新春 Tech blog」シリーズ2日目の記事です。
LINE証券ではTypeScriptを使用しており、TypeScriptの力を最大限発揮させられるように日々取り組んでいます。そこで、この記事ではLINE証券のプロダクション用コードで実際にConditional Typesが使用された事例をご紹介します。Conditional TypesはTypeScript 2.8で追加された機能で、部分型関係に基づいた型レベルの条件分岐やパターンマッチができる機能です。TypeScriptの機能の中でも高度な部類であり、一部の型に凝ったライブラリや曲芸的な遊びを除いてはなかなか実用例を聞かないものです。
我々が書いたコードでConditional Typesが使われた事例をご紹介することで、皆さんにもConditional Typesを身近に感じていただき、ぜひ自分のプロダクトでも活用機会を探していただきたいと思います。
非同期処理を表すAsyncData型
まずは、我々が最近導入したAsyncData型をご紹介します。これは非同期処理の現在の状態を「読み込み中」「成功」「失敗」の3種類で表すための型で、discriminated unionとして知られる超頻出パターンを用いています。探せばこのような型を提供してくれるライブラリもありそうですが、これくらいは自分たちでメンテナンスできそうなのでプロダクションコードに含めています。
export type AsyncDataLoading = {
status: 'loading'
}
export type AsyncDataSucceeded<T> = {
status: 'loadSucceeded'
data: T
}
export type AsyncDataError<Err> = {
status: 'loadFailed'
error: Err
}
export type AsyncData<T, Err> =
| AsyncDataLoading
| AsyncDataSucceeded<T>
| AsyncDataError<Err>
データフェッチライブラリ(useSWRやReact Queryなど)を使っている場合はこれらに相当する型定義がすでに提供されているはずですが、我々はこのAsyncData型をAPIレイヤー(Web APIとの通信を担当するレイヤー)ではなくアプリケーションのコンポーネント設計に関わるより内側のレイヤーの住人として捉えています。すなわち、このAsyncData型はUIコンポーネントのpropsとして渡されたり、コンポーネント間で受け渡されたりするデータとして作られたのです。
その理由は、「読み込みに時間がかかる」とか「失敗の可能性がある」といった性質を、APIレイヤーの中だけに隠蔽することが不可能だと考えたからです。例えばローディング中はスケルトンを表示するとした場合、アプリを構成する末端のUIコンポーネントまで「読み込み中」の情報を伝える必要が出てきます。加えて、APIで発生したエラーに応じて適切なエラーメッセージ等を表示する必要があり、その仕様は我々のアプリではかなり細かく定義されています。よってエラーのハンドリングはビジネスロジックの一部であり、これらのハンドリングはAPIレイヤーよりも内側で行われなければなりません。
また、アプリが大規模になるほどいわゆる「関心の分離」の重要性が増し、UIを担当するコンポーネントたちを生のAPIレスポンスに依存させずにビジネスロジック上の表現のみを見るようにすることが必要です。AsyncDataは、このようにAPIという概念から離れたモデリングの道具として用意されました。上のAsyncData型をよく見るとTだけでなくErrの内容も固定ではなく型引数で与えられるようになっていますが、これはAPIの正常系のレスポンスはもちろん、エラーの場合もAPIインターフェースに非依存なビジネスロジックの表現を用いるためです。
以上の考えから、UIを担当するコンポーネントは具体的なAPIインターフェースを知らず、しかし依然として「読み込み中」や「失敗」の可能性があるデータを受け取ることになります。このようなコンポーネントが取り扱うための、APIという概念から分離された汎用的なコンテナがAsyncDataなのです。
AsyncDataと型の絞り込み
先ほど、AsyncDataはdiscriminated unionパターンを用いているとご紹介しました。このパターンが優れているのは、型の絞り込みが行える点です。我々のコードでは、「絞り込み後の型」もいくつか定義しています。例えば、「エラーの可能性がないAsyncData」の型は次のように定義できます。
export type AsyncDataNotError<T> =
| AsyncDataLoading
| AsyncDataSucceeded<T>
これにより、エラーハンドリングを行うコンポーネントとそれ以外の処理を行うコンポーネントを分けるようなことも可能です。例えば、エラーハンドリングを行う上層のコンポーネントは次のように書くことができます。
if (asyncData.status === 'loadFailed') {
return <ErrorDisplay />;
}
// ここではasyncDataはAsyncDataNotError型になっている
return <Contents data={asyncData} />;
わざわざエラーと残り2つという分け方をせずに3種類の分岐を全部ここに書くことも可能でしょうが、複数のAsyncDataに対するエラーハンドリングを1箇所でまとめてやる場合などはこのようなパターンが実用されます。
この場合、Contentsのpropsにはdataの型をAsyncDataNotErrorと書きます。そうすることで、このデータはもうエラーハンドリング済のデータでありContentsの内部でエラーハンドリングを行う必要がないということが明確になります。
AsyncDataに対するmap
ここからが本題です。コンポーネント内では、AsyncDataに対するmap(中身のデータの変換)を行いたくなることがあります(mapというと配列が思い浮かぶ読者の方も多いかもしれませんが、RustではOptionに対してmapメソッドがあったり、HaskellではFunctorに使えるfmapがあったりしますので、それの類型だと思いましょう)。
これを普通に書くと、次のようになるはずです。
export function mapAsync<T, NewT, Err>(
ad: AsyncData<T, Err>,
mapper: (value: T) => NewT
): AsyncData<NewT, Err> {
switch (ad.status) {
case 'loading': {
return {
status: 'loading'
}
}
case 'loadSucceeded': {
return {
status: 'loadSucceeded',
data: mapper(ad.data)
}
}
case 'loadFailed': {
return {
status: 'loadFailed',
error: ad.error
}
}
}
}
実際のユースケースとしては、いろいろなデータがまとまって渡ってきたAsyncDataを、個々のデータを表示するコンポーネントに渡すために要素ごとのAsyncDataに分割して渡すといった使い道があります。
これも便利なのですが、前述の型の絞り込みと組み合わせると問題がありました。それは、このmapAsyncにAsyncDataNotLoadedを渡すとAsyncDataに戻ってしまうということです。mapAsyncの中身を見れば、返り値がエラーとなるのは入力がエラーだった場合だけなのが分かりますが、この型定義ではそれが表現されていません。
例えばmapAsyncNotErrorのような関数を別に用意して使い分けるといった解決策もありますが、それはあまりスマートではありません。そこで、今回はmapAsyncの型定義をより正確にして、返り値がエラーとなるのは入力がエラーだった場合のみであるというロジックを型で書くことにしました。そう、ここで活躍するのがConditional Typesなのです。
ということで、実際にやってみたのがこちらです(実際の実装ではmapAsyncでdataではなくerrorもmapできる実装が入っているのでもう少し複雑ですが、ここでは簡略化した例をお見せしています)。
type MappedAsyncData<
AD extends AsyncData<unknown, unknown>,
NewT,
> = AD extends AsyncDataSucceeded<unknown>
? AsyncDataSucceeded<NewT>
: AD
type AsyncDataValueType<
AD extends AsyncData<unknown, unknown>
> = AD extends AsyncDataSucceeded<infer T> ? T : never
type AsyncDataErrorType<
AD extends AsyncData<unknown, unknown>
> = AD extends AsyncDataError<infer T> ? T : never
export function mapAsync<
AD extends AsyncData<unknown, unknown>,
NewT,
>(
ad: AD,
mapper: (arg: AsyncDataValueType<AD>) => NewT,
): MappedAsyncData<AD, NewT> {
type T = AsyncDataValueType<AD>
switch (ad.status) {
case 'loadSucceeded': {
const data = ad.data as T
const mapped: AsyncDataSucceeded<NewT> = {
status: 'loadSucceeded',
data: mapper(data)
}
return mapped as MappedAsyncData<AD, NewT>
}
case 'loadFailed': {
const adError: AsyncDataError<AsyncDataErrorType<AD>> = {
status: 'loadFailed',
error: ad.error as AsyncDataErrorType<AD>
}
return adError as MappedAsyncData<AD, NewT>
}
case 'loading': {
const adLoading: AsyncDataLoading = {
status: 'loading'
}
return adLoading as MappedAsyncData<AD, NewT>
}
}
}
内部実装はちょっとasが増えましたがあまり変わっていません。ご存知の通りasの使用は危険なので危険性を最小限にすべきですが、上の例ではasの使用の直前に当該の変数(mappedやadErrorなど)に対して型注釈を明記することで、危険性の影響が及ぶ範囲をそれぞれ1行だけに留めるという工夫が入っています。
それよりも、注目すべきはmapAsyncの型定義です。せっかくなので、どのように考えてこのような型定義が生まれるのかについて解説します。
mapAsyncの型定義の解説
まずはmapAsyncが受け取る型引数です。前の実装ではT, NewT, Errでしたが、今回はADとNewTになっています。TとErrの情報はAD内に入っており、必要に応じて補助的に定義したAsyncDataValueTypeやAsyncDataErrorTypeを使って取り出すことができます(ここでConditional Typesが使われていますね)。ここでAD(extends AsyncData<unknown, unknown>とされていることから、これが渡されたAsyncDataオブジェクトの型を意図していることが分かります)を受け取るように変えたことで、絞り込み後の情報が型引数として取れるようになっています。例えば、絞り込み前ならADに入るのはAsyncData<T, Err>ですが、絞り込み後ならADに入るのはAsyncDataNotError<T>になります。このような書き方をすることで、渡されたオブジェクトの情報を型定義の中で最大限活用することができます。これは複雑な型定義を書きたい場合に重要なテクニックです。
関数の返り値の型は MappedAsyncData<AD, NewT> となっています。これは「ADの中身のデータの型をNewTに変えた型」と読むことができます。そしてこのMappedAsyncData型の定義を見ると、そこにはConditional Typeがひとつ書かれています。これは「ADがAsyncDataSucceeded<unknown>なら中身をNewTにしたAsyncDataSucceeded<NewT>に変えて、それ以外はそのまま」という意味に見えます。
これがうまく動作するのはConditional Typesのunion distributionという挙動があるからです。これはConditional Typesの有用性の半分くらいを支える機能であり、ユニオン型に対して所定の条件下でConditional Typesを使った場合に、ユニオン型を分解してその各要素に対して条件分岐を行うというものです。今回、ADが実際にはAsyncDataLoading | AsyncDataSucceeded<T> | AsyncDataError<E> という型だった場合、このうちAsyncDataSucceeded<T>部分のみが影響を受けて他はそのままになるため、結果はAsyncDataLoading | AsyncDataSucceeded<NewT> | AsyncDataError<E> となります。そして、注目すべきことに、もしADに渡されたのがAsyncDataLoading | AsyncDataSucceeded<T> だった場合には、同じ理屈により AsyncDataLoading | AsyncDataSucceeded<NewT> となります。すなわち、入力にエラーの可能性が無ければ、その性質が出力にも受け継がれる型定義になっているのです。union distributionは、このようにユニオン型の形を保ったまま各要素に対して処理を行いたい場合にとても重宝します。
以上により、入力がすでに絞り込まれている場合には絞り込みを保ったまま変換を行ってくれるようなmapAsyncの型を定義することができました。これで、この型を使う側は今AsyncDataが絞り込まれているかどうかを気にせずにいつでもmapAsyncを使うことができます。
まとめ
この記事では、LINE証券で使われているAsyncDataというutility型を例に出し、プロダクションのコードでTypeScriptのConditional Typesを活用できた事例をご紹介しました。皆さんも、自分たちで定義した型や関数がちょっと不便だなと思ったらConditional Typesで改善できないか考えてみましょう。