結論: T extends Array<infer U>
を用いる
あけましておめでとうございます(半年遅れ)
最近業務で Headless CMS を触ったのですが、技術調査の際に候補に上がった Strapi がずっと気になって最近ので最近遊んでいるのですが、その際に API レスポンスの型を TypeScript で表現するときに利用した手法が面白かったので共有します
Strapi の公式ドキュメントを例にとると、扱う entry(ドメイン・データ)は下記の interface で表現できます
interface Restaurant {
title: string
description: string
}
単体の entry の取得 API のレスポンスは下記になります
{ "data": { "id": 1, "attributes": { "title": "Restaurant A", "description": "Restaurant A's description" }, "meta": { "availableLocales": [] } }, "meta": {} }
ドメインの型が attributes
に含まれるラップされた形式になります
汎用化させるためジェネリクスで型を定義すると以下のようになります
interface Data<T> {
id: number
attributes: T
meta: unknown
}
interface SingleResponse<T> {
data: Data<T>
meta: unknown
}
const restaurantResponse: SingleResponse<Restaurant> = ...
restaurantResponse.data.attributes // => { title: '...', description: '...' }
meta
は扱わないので unknown
型としています
一方で一覧のレスポンスは data
が先ほどのラップされた型の配列になります
{ "data": [ { "id": 1, "attributes": { "title": "Restaurant A", "description": "Restaurant A's description" }, "meta": { "availableLocales": [] } }, { "id": 2, "attributes": { "title": "Restaurant B", "description": "Restaurant B's description" }, "meta": { "availableLocales": [] } }, ], "meta": {} }
これを汎用的に定義すると以下のようになります
interface Data<T> {
id: number
attributes: T
meta: unknown
}
interface ListResponse<T> {
data: Data<T>[]
meta: unknown
}
const restaurantResponse: ListResponse<Restaurant> = ...
restaurantResponse.data[0].attributes // => { title: '...', description: '...' }
どちらのケースでも Data
型が同じであることから SingleResponse
と ListResponse
をまとめて汎用化できそうに見えます
interface Response<T> {
data: ???
meta: unknown
}
type SingleResponse = Response<Restaurant>
type ListResponse = Response<Restaurant[]>
ここで、T
を SingleResponse
と ListResponse
に置き換えて結果のイメージを見てみましょう
// ListResponse
interface Response<Restaurant[]> {
data: Data<Restaurant>[] // Restaurant をラップした型の配列
meta: unknown
}
// SingleResponse
interface Response<Restaurant> {
data: Data<Restaurant> // Restaurant をラップした型
meta: unknown
}
T
は Restaurant[]
もしくは Restaurant
のどちらを取ることもでき、それぞれで data
の型が変わります
この表現を行うためには下記の2点の課題があります
- パラメータ型
T
が配列かどうかの判定 - パラメータ型
T
が配列の場合に配列の型の抽出
これらの課題は Conditional Types を用いることでクリアすることができます
Conditional Types では入力された型に対して判定を行い最終的な型を決定します
今回の場合は、まず T
が配列かどうかを extends
を用いて判定できます
interface Response<T> {
data: T extends Array ? /* 配列の場合の型 */ : /* 配列でない場合の型 */
meta: unknown
}
配列の場合に必要な配列の型の抽出は infer
を用いることで行えます
interface Response<T> {
data: T extends Array<infer U> ? Data<U>[] : Data<T>
meta: unknown
}
infer
を用いることで型の推測が行われ、その結果を型パラメータ U
で表現することができるようになります
T
を実際の型に置き換えると下記のようなイメージになります
// ListResponse
interface Response<Restaurant[]> {
data: Restaurant[] extends Array<infer Restaurant> ? Data<Restaurant>[] : /* Data<Restaurant[]> */
meta: unknown
}
// SingleResponse
interface Response<Restaurant> {
data: Restaurant extends Array<infer never> ? /* Data<never>[] */ : Data<Restaurant>
meta: unknown
}
これにより interface
の汎用化が行えました、やったね
最終的な結果は下記のプレイグラウンドで試すことができます
おまけ: infer
のその他のユースケース
公式サイトでは関数の型からの戻り値の算出のユースケースがありました
type GetReturnType<T> = T extends (...args: never[]) => infer R ? R : never
const sum = (a: number, b: number) => a + b
type SumReturnType = GetReturnType<typeof sum> // number
type ConsoleReturnType = GetReturnType<typeof console.log> // void
type StringReturnType = GetReturnType<"Text"> // never
おもしろーい