Tagbangers Blog

[型パズル] Q. TypeScript の型定義でジェネリクスのパラメータが配列かどうかで型の形式を変えたい!

結論: T extends Array<infer U> を用いる


あけましておめでとうございます(半年遅れ)

最近業務で Headless CMS を触ったのですが、技術調査の際に候補に上がった Strapi がずっと気になって最近ので最近遊んでいるのですが、その際に API レスポンスの型を TypeScript で表現するときに利用した手法が面白かったので共有します

REST API

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 型が同じであることから SingleResponseListResponse をまとめて汎用化できそうに見えます

interface Response<T> {
  data: ???
  meta: unknown
}

type SingleResponse = Response<Restaurant>
type ListResponse = Response<Restaurant[]>

ここで、TSingleResponseListResponse に置き換えて結果のイメージを見てみましょう

// ListResponse
interface Response<Restaurant[]> {
  data: Data<Restaurant>[] // Restaurant をラップした型の配列
  meta: unknown
}

// SingleResponse
interface Response<Restaurant> {
  data: Data<Restaurant> // Restaurant をラップした型
  meta: unknown
}

TRestaurant[] もしくは 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 の汎用化が行えました、やったね

最終的な結果は下記のプレイグラウンドで試すことができます

Playground

おまけ: 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

おもしろーい