🛡️

openapi-generator-cli による TypeScript 型定義

2021/03/23に公開

openapi-generator-cli を利用すると、OpenAPI 定義から TypeScript で利用可能なアセットを生成することができます。しかし最適な成果物を得るためには、OpenAPI 定義自体に少し工夫が必要で、一筋縄にはいかないことがあります。

本稿及びサンプルリポジトリでは、openapi-generator-cli と TypeScript 4.2 の新機能を活用したアプローチを紹介します。

課題点の確認

OpenAPI 定義にあたりresponsesフィールドのschemaに対し、インラインで定義を追加することが多いのではないでしょうか?OpenAPI を定義する GUI 「Stoplight Studio」 などでは、このインラインスキーマ定義が直感的で使いやすく、yaml を直接編集しなくても良いなど、開発に重宝します。

responses:
  "200":
    description: OK
    content:
      application/json:
        schema:
          description: ""
          type: object
          properties:
            id:
              type: string
            name:
              type: string
            age:
              type: number
            gender:
              type: string
          required:
            - id
            - name
            - age

しかしながらこの定義のままでは、下記InlineResponse200の様な、望まない型定義出力となってしまいます。この連番型定義では何を指しているのか不明瞭で、動的に変更してしまうリスクも伴い、使い物になりません。

/**
 *
 * @export
 * @interface InlineResponse200
 */
export interface InlineResponse200 {
  /**
   *
   * @type {string}
   * @memberof InlineResponse200
   */
  id: string;
  /**
   *
   * @type {string}
   * @memberof InlineResponse200
   */
  name: string;
  /**
   *
   * @type {number}
   * @memberof InlineResponse200
   */
  age: number;
  /**
   *
   * @type {string}
   * @memberof InlineResponse200
   */
  gender?: string;
}

これを乗り越えるためには、別途 Model 定義を用意し、$ref参照しなければいけません。この課題のためだけに全て Model 定義をするとなると、工数に見合わなくなることもあります。本稿の主旨はこの 「インラインスキーマ定義はそのままで、型定義のみ抽出する」 ことです。

一意の operationId は必ず付与・管理すること

openapi-generator-cli の typescript-axios を使う場合、成果物として ApiFactory 関数を得ることができます(以下DefaultApiFactory関数)ApiFactory 関数で生成された axios fetcher は、OpenAPI 定義内訳が伝搬したヘルパー関数です。さきのInlineResponse200などが型注釈されており、この axios fetcher を使う限りでは型安全を担保することができます。

import { DefaultApiFactory } from "../openapi/dist/api";

export const api = DefaultApiFactory();
export async function main() {
  try {
    const { data } = await api.usersGet();
    const usersId = data.users.map((user) => user.id);
    console.log(usersId);
  } catch (err) {
    console.log(err);
  }
}

この axios fetcher、await api.usersGet()の関数名usersGetは、OpenAPI 定義のoperationIdに由来します。 第一関門として、一意の operationId は必ず付与しなければいけません。 operationId の管理がおざなりになっている場合、まずはここから正す必要があります。

axios fetcher を使いたくない場合

axios fetcher を利用したくないケースもあるかもしれません。(axios fetcher が重厚すぎる、サポート対象環境で不具合が発生する、など)そうなると、TypeScript コードで恩恵を受けることが出来なくなります。そこで、TypeScript の type inference in conditional types(いわゆる型パズル)の登場です。

ResponseBody 型の抽出

以下の型パズルを利用すると、axios fetcher の全 ResponseBody を抽出することができます。戻り値型注釈を抽出するだけなので、特別目新しいものではありません。

export const api = DefaultApiFactory();
type InferPromise<T> = T extends (...arg: any[]) => Promise<infer I>
  ? I
  : never;
type InferAxiosResonse<T> = T extends AxiosResponse<infer I> ? I : never;
type InferResponseBody<T> = {
  [K in keyof T]: InferAxiosResonse<InferPromise<T[K]>>;
};
export type ResponseBody = InferResponseBody<typeof api>;
// ______________________________________________________
//
// operationId 由来の関数名で添字アクセスする
//
type T1 = ResponseBody["usersGet"];
type T2 = ResponseBody["usersUserIdGet"];

RequestBody 型の抽出

以下の型パズルを利用すると、axios fetcher の全 RequestBody を抽出することができます。axios fetcher は、「最後から二番目の引数が RequestBody 相当」 という特徴をもっており、そこに目をつけています。

export const api = DefaultApiFactory();
type InferSecondFromBack<T> = T extends [...any[], infer U, any] ? U : never;
type InferFunctionArgs<T> = T extends (...arg: infer U) => any
  ? Required<U> // optional の場合抽出できないため、Required変換
  : never;
type InferRequestBody<T> = {
  [K in keyof T]: InferSecondFromBack<InferFunctionArgs<T[K]>>;
};
export type RequestBody = InferRequestBody<typeof api>;
// ______________________________________________________
//
// operationId 由来の関数名で添字アクセスする
//
type T3 = RequestBody["usersUserIdPatch"];
type T4 = RequestBody["usersPost"];

これまで「最後から n 番目の引数」の様な型抽出は不可能でしたが、TypeScript 4.2から抽出が可能になりました。InferSecondFromBack<InferFunctionArgs<T[K]>> の様に指定することで「最後から二番目の引数」を導出することができます。

複雑な型定義はこの様な局面で役に立つことがありますので、覚えておくと便利です。

Discussion