🧩

TS 型パズル!Generator を使わない OpenAPI の型生成

に公開

はじめに

OpenAPI 使っていますか?
OpenAPI とは、REST API の定義を書くことができるフォーマットです。
OpenAPI が公開されていると、ドキュメントの代わりにになったり、各言語向けのクライアントの生成を自動で行うことができたりするため、利用者にとってはメリットが大きいです。

一方で、開発側(サーバー側)にとっては、実装とそれに合った OpenAPI 定義の両方を保守する必要があり、負担が大きくなってしまうというデメリットがあります。

それを解消するため、主に以下の手法があります。

  1. OpenAPI の定義からサーバー側のコードを自動で生成する
  2. 実装から OpenAPI の定義を自動で生成する

OpenAPI の定義ベースに開発(スキーマ駆動開発)をしているか、実装ベースに OpenAPI 定義を作成しているかで取りやすい手法は異なってきます。

本記事では、

  • TypeScript を使ってサーバーを書いている
  • 1 の OpenAPI の定義ベースに開発(スキーマ駆動開発)を行っている

人を対象に、より便利な API 型生成ライブラリを作ってみた
というお話になります!

対象

  • TypeScript を使ってサーバー開発をしている人
  • 既に OpenAPI 定義があって、その定義を満たす API サーバーを作りたい人
  • または、スキーマ駆動開発をしてみたい人
  • など…

OpenAPI の定義から TypeScript の型を生成

OpenAPI の定義から TypeScript の型を生成することができるツールは既にいくつかあります

しかし、どの生成ツールにも共通して言えるのは、

OpenAPI から型定義を事前に生成(ビルド)しておく必要がある

ということです。

  • あらかじめスキーマは決まっていたけど、新たにこれが必要になったな
  • 逆に、このプロパティ要らないんじゃないか?

開発している人なら、こんな経験、よくあると思います。
そのたびに、型を再生成するのって手間じゃないでしょうか?

  • あれ?型エラーがでてるな🤔 OpenAPI 修正したのに…あ!型定義ビルド忘れてた😥

そんな時間、無駄だと思いませんか?


ということで、OpenAPI 定義からリアルタイムに(LSP 内で)型を生成できないか試してみました。

型パズルを使って OpenAPI 定義から型を生成する

長くなりましたが、ようやく本題に入ります。
ここから、型パズルを使って、OpenAPI の型を生成するコードを書いていきます!

下準備

まずは、LSP に OpenAPI ファイルを読み込ませる必要があります。
OpenAPI は yml でも json でも書くことができますが、yml だとさすがに扱いずらいので、何かしらのツールで json にしておきましょう。

json にしても、そのまま LSP に入力させるのは難しいです。
そのため、json を ts にします。
といっても、json の構文はそのまま使えるので、コピペ + 少し追加 で ts に変換できます

openapi.json
{
  "openapi": "3.1.0",
  "info": {
    "version": "1.0.0",
    "title": "Swagger Petstore",
  }
  ...
}

openapi.ts
export const openapi = {
  "openapi": "3.1.0",
  "info": {
    "version": "1.0.0",
    "title": "Swagger Petstore",
  }
  ...
} as const;

のように、変数定義だけ追加して、オブジェクトの部分はそのままコピペでいけるはずです。
ここでミソなのが、as const を最後に追加する、という所です。
as const を付けることで、openapi 変数の型が、

type OpenAPI = {
  openapi: string,
  info: {
    version: string,
    title: string,
  }
  ...
}

みたいな汎用的な型にならず、下記のように、文字列・数値などもリテラルとして認識されます。

type OpenAPI = {
  openapi: "3.1.0",
  info: {
    version: "1.0.0",
    title: "Swagger Petstore",
  }
  ...
}

🧩 型パズル

ここから、ごりごり🧩していきます!

$ref の解消

OpenAPI の定義には、特定の箇所で $ref を使うことができます。
$ref とは、他の箇所に書いてある定義を参照するプロパティなのですが、これがあると型パズルが複雑になってしまうため、最初に $ref を解消してしまいます。

/** reference を展開する型 */
type FlatRef<T, Base = T> = T extends { readonly $ref: `#/${infer U}` }
  ? InferReferenceType<U, Base>
  : {
    [K in keyof T]: FlatRef<T[K], Base>;
  };
type InferReferenceType<T extends string, Base, Now = Base> = T extends
  `${infer U}/${infer V}`
  ? (Now extends { [_ in U]: infer W } ? InferReferenceType<V, Base, W>
    : never)
  : (Now extends { [_ in T]: infer W } ? FlatRef<W, Base> : never);

いきなり複雑な型になってしまいましたね…

詳しいことは省きますが、$ref があるところを再帰的に探索して、あれば、その指定をもとに、参照先の型で置換する。ということを行っています。

基本の型を生成する

次は、下記のような OpenAPI の型定義(Scheme Object)から、TypeScript の型定義を生成する型を作ります。

{ "type": "number" }
/** string 型 */
type InferString<T> = T extends { readonly enum: readonly [...infer U] }
  ? U[number]
  : string;

/** integer 型 */
type InferInteger<T> = T extends { readonly enum: readonly [...infer U] }
  ? U[number]
  : number;

/** object 型 */
type InferObject<T> = T extends
  { readonly properties: infer V; readonly required?: readonly [...infer U] } ?
    & { [K in Extract<keyof V, U[number]>]: SchemaType<V[K]> } // SchemaType は後で作ります🙇
    & { [K in Exclude<keyof V, U[number]>]?: SchemaType<V[K]> }
  : never;

/** array 型 */
type InferArray<T> = T extends { readonly items: infer U } ? SchemaType<U>[]
  : never;

type InferType<T> = T extends { readonly type: "integer" } ? InferInteger<T>
  : T extends { readonly type: "number" } ? number
  : T extends { readonly type: "string" } ? InferString<T>
  : T extends { readonly type: "boolean" } ? boolean
  : T extends { readonly type: "object" } ? InferObject<T>
  : T extends { readonly type: "array" } ? InferArray<T>
  : T extends { readonly type: "null" } ? null
  : never;

最後の InferType から追っていくと見やすいかもしれません。
単純なのは、T extends { readonly type: "number" } ? number という所でしょうか。
T に渡されたオブジェクトの中に type: "number" が含まれていれば、それは number 型と返す
ようになっています。

他の型についても似たように生成しています。(一応、string と integer だけ enum にも対応してみました)

複合型にも対応する

OpenAPI では oneOfallOf という定義もあり、それぞれ TS のユニオン型、インターセプション型に対応させることができるので、その対応もしていきます。

/** oneOf の型を生成 */
type MapForOneOfType<T> = T extends [infer U, ...infer U2]
  ? SchemaType<U> | MapForOneOfType<U2>
  : never;

/** allOf の型を生成 */
type MapForAllOfType<T> = T extends [infer U, ...infer U2]
  ? SchemaType<U> & MapForAllOfType<U2>
  : Record<never, never>;

/** スキーマの型を生成 */
// 先に上の基本の型で出てきてしまいましたね🙇
type SchemaType<T> = T extends
  { readonly oneOf: readonly [...infer U] } ? MapForOneOfType<U>
  : T extends { readonly allOf: readonly [...infer U] } ? MapForAllOfType<U>
  : InferType<T>;

パス・メソッド・ステータスコード・コンテンツタイプの一覧を表す型を作る

後で、あるパスの、あるメソッドの、あるステータスコードの、あるコンテンツタイプのレスポンス型を取得する型を作るのですが、その時に使用する型を作っていきます。

/** 定義されているパスの型を生成 */
type Paths<Base> = Base extends { paths: infer Paths } ? keyof Paths
  : never;

/** 指定したパスに定義されているメソッドの型を生成 */
type Methods<Path extends Paths<Base>, Base> = Base extends
  { paths: { [_ in Path]: infer Methods } } ? Extract<
    keyof Methods,
    "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trase"
  >
  : never;

/** 指定したパス・メソッドのレスポンスとして定義されているステータスコードの型を作成 */
type ResposeStatusCode<
  Path extends Paths<Base>,
  Method extends Methods<Path, Base>,
  Base,
> = Base extends
  { paths: { [_ in Path]: { [_ in Method]: { responses: infer Responses } } } }
  ? keyof Responses
  : never;

/** 指定したパス・メソッド・ステータスコードに定義されている ContentType の型を作成 */
type ResponseContentType<
  Path extends Paths<Base>,
  Method extends Methods<Path, Base>,
  StatusCode extends ResposeStatusCode<Path, Method, Base>,
  Base,
> = Base extends {
  paths: {
    [_ in Path]: {
      [_ in Method]: {
        responses: {
          [_ in StatusCode]: { content: infer ContentType };
        };
      };
    };
  };
} ? keyof ContentType
  : never;

Paths はそのまま paths のユニオン型、
Methods は Path を指定してその中にある HTTPメソッドのユニオン型、
ResponseStatusCode は Path と Method を…

という感じで一覧の型を作っています。

レスポンスの型を生成

やっと、レスポンスの型を生成できるようになりました。

// 複雑な型を展開してくれる型
// 参考:https://bema.jp/articles/20241222/
export type NestedIdentify<T> = T extends object
  ? T extends infer U ? { [K in keyof U]: NestedIdentify<U[K]> }
  : never
  : T;

/** レスポンスの型を生成 */
export type ResponseType<
  Base,
  Path extends Paths<Base>,
  Method extends Methods<Path, Base>,
  StatusCode extends ResposeStatusCode<Path, Method, Base>,
  ContentType extends ResponseContentType<Path, Method, StatusCode, Base>,
> = NestedIdentify<
  FlatRef<Base> extends {
    paths: {
      [_ in Path]: {
        [_ in Method]: {
          responses: {
            [_ in StatusCode]: {
              content: {
                [K in ContentType]: { schema: infer U };
              };
            };
          };
        };
      };
    };
  } ? SchemaType<U>
    : never
>;

これまでに作った各種型を使用して、レスポンスの型を生成しています。

ちょっとしたおまじないとして、NestedIdentify という型で ResponseType で返す型をラップしています。
これがないと、VSCode のホバー時に出る型定義などで、(型自体はできているけれども)型パズルの内容が表示されてしまって、実際にどんな型か分から失くなってしまいます。

詳しくは、参考にした記事 URL の方をご覧ください。

完成!

実際に作成したコード全文をおいておきます。
筆者は、ここまでのコードを main.ts にまとめました。

コード全文
main.ts
import { NestedIdentify } from "./util.ts";

/** $ref を展開する型 */
type FlatRef<T, Base = T> = T extends { readonly $ref: `#/${infer U}` }
  ? InferReferenceType<U, Base>
  : {
    [K in keyof T]: FlatRef<T[K], Base>;
  };
type InferReferenceType<T extends string, Base, Now = Base> = T extends
  `${infer U}/${infer V}`
  ? (Now extends { [_ in U]: infer W } ? InferReferenceType<V, Base, W>
    : never)
  : (Now extends { [_ in T]: infer W } ? FlatRef<W, Base> : never);

/** string 型 */
type InferString<T> = T extends { readonly enum: readonly [...infer U] }
  ? U[number]
  : string;

/** integer 型 */
type InferInteger<T> = T extends { readonly enum: readonly [...infer U] }
  ? U[number]
  : number;

/** object 型 */
type InferObject<T> = T extends
  { readonly properties: infer V; readonly required?: readonly [...infer U] } ?
    & { [K in Extract<keyof V, U[number]>]: SchemaType<V[K]> }
    & { [K in Exclude<keyof V, U[number]>]?: SchemaType<V[K]> }
  : never;

/** array 型 */
type InferArray<T> = T extends { readonly items: infer U } ? SchemaType<U>[]
  : never;

type InferType<T> = T extends { readonly type: "integer" } ? InferInteger<T>
  : T extends { readonly type: "number" } ? number
  : T extends { readonly type: "string" } ? InferString<T>
  : T extends { readonly type: "boolean" } ? boolean
  : T extends { readonly type: "object" } ? InferObject<T>
  : T extends { readonly type: "array" } ? InferArray<T>
  : T extends { readonly type: "null" } ? null
  : never;

/** oneOf の型を生成 */
type MapForOneOfType<T> = T extends [infer U, ...infer U2]
  ? SchemaType<U> | MapForOneOfType<U2>
  : never;

/** allOf の型を生成 */
type MapForAllOfType<T> = T extends [infer U, ...infer U2]
  ? SchemaType<U> & MapForAllOfType<U2>
  : Record<never, never>;

/** スキーマの型を生成 */
type SchemaType<T> = T extends { readonly oneOf: readonly [...infer U] }
  ? MapForOneOfType<U>
  : T extends { readonly allOf: readonly [...infer U] } ? MapForAllOfType<U>
  : InferType<T>;

/** 定義されているパスの型を生成 */
type Paths<Base> = Base extends { paths: infer Paths } ? keyof Paths
  : never;

/** 指定したパスに定義されているメソッドの型を生成 */
type Methods<Path extends Paths<Base>, Base> = Base extends
  { paths: { [_ in Path]: infer Methods } } ? Extract<
    keyof Methods,
    "get" | "put" | "post" | "delete" | "options" | "head" | "patch" | "trase"
  >
  : never;

/** 指定したパス・メソッドのレスポンスとして定義されているステータスコードの型を作成 */
type ResposeStatusCode<
  Path extends Paths<Base>,
  Method extends Methods<Path, Base>,
  Base,
> = Base extends
  { paths: { [_ in Path]: { [_ in Method]: { responses: infer Responses } } } }
  ? keyof Responses
  : never;

/** 指定したパス・メソッド・ステータスコードに定義されている ContentType の型を作成 */
type ResponseContentType<
  Path extends Paths<Base>,
  Method extends Methods<Path, Base>,
  StatusCode extends ResposeStatusCode<Path, Method, Base>,
  Base,
> = Base extends {
  paths: {
    [_ in Path]: {
      [_ in Method]: {
        responses: {
          [_ in StatusCode]: { content: infer ContentType };
        };
      };
    };
  };
} ? keyof ContentType
  : never;

/** レスポンスの型を生成 */
export type ResponseType<
  Base,
  Path extends Paths<Base>,
  Method extends Methods<Path, Base>,
  StatusCode extends ResposeStatusCode<Path, Method, Base>,
  ContentType extends ResponseContentType<Path, Method, StatusCode, Base>,
> = NestedIdentify<
  FlatRef<Base> extends {
    paths: {
      [_ in Path]: {
        [_ in Method]: {
          responses: {
            [_ in StatusCode]: {
              content: {
                [K in ContentType]: { schema: infer U };
              };
            };
          };
        };
      };
    };
  } ? SchemaType<U>
    : never
>;

この後、実際に使ってみます!

使い方

サンプルで使用する OpenAPI 定義

今回使用する OpenAPI 定義は以下のものです。

OpenApi v3.1 PetStore example

公式のサンプルとして PetStore があるのですが、少し定義が多かったので、
いい感じに削られた?定義を見つけ、これを使うことしました。

TypeScript にした定義全文
openapi.ts
// reference https://gist.github.com/seriousme/55bd4c8ba2e598e416bb5543dcd362dc

export const openapi = {
  "openapi": "3.1.0",
  "info": {
    "version": "1.0.0",
    "title": "Swagger Petstore",
    "license": {
      "name": "MIT",
      "url": "https://opensource.org/licenses/MIT",
    },
  },
  "servers": [
    {
      "url": "http://petstore.swagger.io/v1",
    },
  ],
  "paths": {
    "/pets": {
      "get": {
        "summary": "List all pets",
        "operationId": "listPets",
        "tags": [
          "pets",
        ],
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "description": "How many items to return at one time (max 100)",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
            },
          },
        ],
        "responses": {
          "200": {
            "description": "A paged array of pets",
            "headers": {
              "x-next": {
                "description": "A link to the next page of responses",
                "schema": {
                  "type": "string",
                },
              },
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Pets",
                },
              },
            },
          },
          "default": {
            "description": "unexpected error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error",
                },
              },
            },
          },
        },
      },
      "post": {
        "summary": "Create a pet",
        "operationId": "createPets",
        "tags": [
          "pets",
        ],
        "responses": {
          "201": {
            "description": "Null response",
          },
          "default": {
            "description": "unexpected error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error",
                },
              },
            },
          },
        },
      },
    },
    "/pets/{petId}": {
      "get": {
        "summary": "Info for a specific pet",
        "operationId": "showPetById",
        "tags": [
          "pets",
        ],
        "parameters": [
          {
            "name": "petId",
            "in": "path",
            "required": true,
            "description": "The id of the pet to retrieve",
            "schema": {
              "type": "string",
            },
          },
        ],
        "responses": {
          "200": {
            "description": "Expected response to a valid request",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Pet",
                },
              },
            },
          },
          "default": {
            "description": "unexpected error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error",
                },
              },
            },
          },
        },
      },
    },
  },
  "components": {
    "schemas": {
      "Pet": {
        "type": "object",
        "required": [
          "id",
          "name",
        ],
        "properties": {
          "id": {
            "type": "integer",
            "format": "int64",
          },
          "name": {
            "oneOf": [{
              "type": "string",
            }, {
              "type": "null",
            }],
          },
          "tag": {
            "type": "string",
          },
        },
      },
      "Pets": {
        "type": "array",
        "items": {
          "$ref": "#/components/schemas/Pet",
        },
      },
      "Error": {
        "type": "object",
        "required": [
          "code",
          "message",
        ],
        "properties": {
          "code": {
            "type": "integer",
            "format": "int32",
          },
          "message": {
            "type": "string",
          },
        },
      },
    },
  },
} as const;

使ってみる

以下のようにすることで、使うことができます。
ResponseType をインポート、

  • OpenAPI の定義自体
  • パス名
  • HTTP メソッド名
  • ステータスコード
  • コンテンツタイプ

を指定して、GetPetsRes という型を定義しました。

import { ResponseType } from "./main.ts";
import { openapi } from "./openapi.ts";

type GetPetsRes = ResponseType<
  typeof openapi,
  "/pets",
  "get",
  "200",
  "application/json"
  >;

VSCode などで、書き、GetPetsRes をホバーすると、以下のように出てきて、きちんと型が定義されていることが分かると思います!

type GetPetsRes = {
    id: number;
    name: string | null;
    tag?: string | undefined;
}[]

この型を使うと、レスポンスデータをこのように定義でき、このオプジェクトを実際の API レスポンスとして返すことで、OpenAPI 定義に沿った開発ができるようになります。

const getPetsResponce: GetPetsRes = [{
  id: 1,
  name: "pochi",
  tag: "dog",
}];

もちろん、openapi.ts を変更すると、型が反映されるはずです。

さいごに

ここまで、型パズルを使った Generator を使わない OpenAPI 定義を作成してきました。

今回作成した部分は、指定した API のレスポンスの型を作成するものですが、少し手を加えることで、リクエストの型も作成することが可能です!

よかったら試してみてください。

この型生成器の問題点として、

  • 複数ファイルにまたがった $ref は参照できない
  • description などの情報を型に反映することができない

などがあるため、実用化できるか…?というと少し疑問が残りますが、
型パズルを使うことでこんなことができるんだ!ということが分かっていただければ幸いです。

また、OpenAPI 3.1 は JSON Schema とも互換があるらしいので、この型パズルは JSON Schema でも応用可能だと思われます。
そちらのほうが汎用性が高いと思う?ので、気になる方はぜひやってみてください!

Discussion