Hono で作る REST API で 仕様と実装を同期しながらクライアントへスキーマ情報共有する方法の最適解をパターン別に検討してみた

Hono で作る REST API で 仕様と実装を同期しながらクライアントへスキーマ情報共有する方法の最適解をパターン別に検討してみた

Hono + Zod OpenAPIの記事は多く出ていますが、何故それを選ぶに至ったかの経緯やクライアント側へのAPI仕様共有方法も含めた検討内容をご紹介します
2025.07.29

こんにちは。リテールアプリ共創部のきんじょーです。

サーバーサイドで REST API を作成するとき、以下のような課題を感じたことはありませんか?

- OpenAPIと実際のAPIレスポンスが乖離している
- OpenAPIと実際のバリデーション仕様が乖離しており400エラーになる
- OpenAPIを手書きでメンテナンスするのが大変、その結果陳腐化している
- APIを呼び出すクライアントを作るのが面倒

上記の課題を解決するために、これまで OpenAPI からバリデーションスキーマと TypeScript の型情報を生成し、仕様と実装を同期する方法を多く取っていました。

具体的には以下のように Express を採用したプロジェクトで express-openapi-validator を利用して API のバリデーションを行い、 openapi2aspida を用いて TypeScript の型情報を生成する方法です。

この方法で OpenAPI と TypeScript の型情報、バリデーションスキーマの同期を実現できました。
しかし、ライブラリが抱える既知の不具合や、それを補うために型定義の利便性が損なわれる問題があり、今回新しいプロジェクトを開始するにあたり最適な技術選定の組み合わせを再考してみました。

既存の構成が抱えていた課題

oneOf, allOf キーワードが使えない

1 つのエンドポイントに複数のリクエスト/レスポンススキーマを割り当てたい場合や、定義済みのスキーマを元に新たなスキーマを定義したい場合、OpenAPI では oneOf, allOf のキーワードを使用可能です。

OpenAPI で定義したスキーマはそのまま TypeScript の型情報になります。そのためoneOfを利用してスキーマを細かく分けて定義することで、判別可能なユニオン型の型を生成することができドメインモデルをより厳密に定義することができます。

OpenAPI でoneOfを利用してスキーマを分けて定義

具体的な例を示します。
以下は LINE 公式アカウントのリッチメニュー押下時のアクションに関するパラメーターを定義したスキーマの一例です。

# スキーマの定義
UrlArea:
    type: object
    properties:
        actionType:
            type: string
            enum:
            - URL
        url:
            type: string
            format: uri
            example: https://example.com
    required:
        - actionType
        - url
CardArea:
    type: object
    properties:
        actionType:
            type: string
            enum:
            - CARD
        data:
            type: string
    required:
        - actionType
        - data

# oneOfで複数スキーマを割り当て
areas:
    type: array
    items:
    type: object
    oneOf:
        - $ref: "#/components/schemas/UrlArea"
        - $ref: "#/components/schemas/CardArea"

生成される TypeScript の型

actionType をタグとした判別可能なユニオン型( Discriminated Union Types )を生成できます。

// 生成される型
export type UrlArea = {
  actionType: "URL";
  url: string;
};

export type CardArea = {
  actionType: "CARD";
  data: string;
};

// 型の絞り込みが容易になる
if (area.actionType === "URL") {
  console.log(area.url); // UrlAreaに絞り込まれる
}

oneOf,allOfキーワードが使えない理由

前述の理由から、OpenAPI では細かくスキーマを分けて定義してoneOfキーワードを利用したいところです。
しかし、oneOfallOfのキーワードを使用して express-openapi-validator でバリデーションをかけた場合、バリデーションが意図するスキーマ通りにかからないという不具合があります。

1 年半ほど前に Issue が起票されていますが、現在も未解決のままです。

https://github.com/cdimascio/express-openapi-validator/issues/902

上記の問題を避けるため、API のインタフェースでは複数のエンティティを Optional 項目を交えて 1 つのスキーマで定義する方法が考えられます。

# スキーマの定義
Area:
  type: object
  properties:
    actionType:
      type: string
      enum:
        - URL
        - CARD
    url:
      type: string
      format: uri
    data:
      type: string
  required:
    - actionType

この方法を取ると、生成される型も Optional なフィールドを多く持ってしまい、タグによる型の絞り込みができなくなります。

// 生成される型
export type Area = {
  actionType: "URL" | "CARD";
  url?: string;
  data?: string;
};

// 型の絞り込みができない
if (area.actionType === "URL") {
  console.log(area.url); // エラー
}

レスポンスのバリデーションがかけられない

1 つのエンドポイントから、リソースの状態に応じて複数のレスポンススキーマを返す API を設計するケースが多々あります。
この場合スキーマをoneOfを使って定義して、レスポンススキーマのバリデーションをかけることで、複数のレスポンス形式のエラーを早い段階で検出できますが、前述の理由によりoneOfが利用できないため、レスポンスのバリデーションを諦めていました。

結局他のバリデーションライブラリが必要になる

当たり前ですが OpenAPI をスキーマの中心に置く構成では、OpenAPI で指定できるスキーマや項目の定義以上のバリデーションをかけることができません。
例えば開始日と終了日が逆転していないか?といった相関チェックをかけようとすると、express-openapi-validator では実装できないため、結局 Zod などのバリデーションライブラリでスキーマを定義する必要がありました。

技術選定にあたり重視する要件

これまでの課題を解消できることに加え、以下の要件を元に構成を検討しました。

  1. OpenAPI とバリデーションスキーマと TypeScript の型を同期できること
  2. リクエスト/レスポンス両方のバリデーションがかけられること
  3. 相関チェックなど複雑なバリデーション要件にも対応できること
  4. 定義したスキーマから型安全に実装できる TS の型を生成できること(できれば API クライアントも)
  5. 既存の Express の構成からの移行が容易であること
  6. ある程度成熟している、かつ今もなおメンテナンスが活発であること

どのスキーマを中心に置くべきか

OpenAPI を中心に置く構成だと、 3 の複雑なバリデーションの課題をどうしても解消できないため、Zod で定義したバリデーションスキーマを中心に置き、バリデーションスキーマから OpenAPI、TypeScript の型を生成する方式を取ることにします。

実は3年ほど前に同様の趣旨の検証ブログを書いているのですが、その際は Fastify と Zod の組み合わせで検証していました。

2025 年の執筆時点で再考したところ、 Hono と 周辺のエコシステムを組み合わせると前述の課題は解消できることに加え、Hono RPC によるクライアント側への API 仕様共有が組み込まれているなど、開発生産性を上げることができそうだったので、Hono と Zod を中心に検証を進めていきます。

Hono + Zod で OpenAPI を生成する方法の検討

Hono が提供する Zod + OpenAPI の連携方法にはZod OpenAPIと、Hono OpenAPIの 2 つのミドルウェアがあります。

Zod OpenAPI

こちらは Hono の作者 yusukebe さんによる Zod とルーティング情報から OpenAPI を生成するミドルウェアです。

https://github.com/honojs/middleware/tree/main/packages/zod-openapi

内部的には@asteasolutions/zod-to-openapiを利用しています。
zod-to-openapi を直接使えば Express の構成のままやりたいことを実現することもできそうでしたが、Express 側のルーティングと zod-to-openapi に渡すルーティング情報の管理を自前で実装する必要がありました。

Hono ではミドルウェアとして API のルーティングと OpenAPI 生成に必要なルーティング情報の統合が提供されているため、その点だけでも今回 Hono を採用したいと考えた理由になりました。

ミドルウェアが提供するバリデーションはリクエストについてのみで、レスポンスのバリデーションについては検討が必要です。

Hono OpenAPI

こちらは 3rd パーティ製のミドルウェアで Zod に加えて Valibot や ArkType など様々なバリデーションライブラリと統合可能です。

https://github.com/rhinobase/hono-openapi

Zod OpenAPI に比べてどのような違いがあるのかは、作者の Aditya Mathur さんによる以下のポストが参考になります。

https://dev.to/mathuraditya7/introducing-hono-openapi-simplifying-api-documentation-for-honojs-2e0e

具体的には以下の違いがあります。

  1. バリデーションスキーマやルーティングの構文が Hono 標準に近い
    1. ライブラリ利用の学習曲線が低い
    2. 既存プロジェクトへの導入ハードルが低い
  2. バリデーションライブラリに Zod 以外 を採用できる
  3. レスポンススキーマについてもバリデーションが可能(ただし Experimental)

今回採用するのは Zod OpenAPI

今回は新規プロジェクトでの採用であり、元々バリデーションスキーマに Zod を使用する予定だったため、Zod OpenAPI を採用します。
どちらを採用したとしても、Hono で Swagger UI をホスティングするミドルウェアは利用できるので、プロジェクトの状況や使用するバリデーションライブラリに応じて選ぶと良いでしょう。

クライアントへの API 仕様の連携方法検討

次はサーバーサイドで定義する API の仕様を、クライアントへどのように連携すべきかを検討していきます。
サーバーサイドで Hono を利用する場合、以下の 3 つの選択肢が考えられます。

  1. Hono RPC を利用する
  2. OpenAPI のエコシステムで API クライアントを生成する
  3. Zod が出力する型情報利用し OpenAPI 仕様を見ながら API クライアントを自作する

開発チームの体制や、クライアントの種類(Web フロント or モバイルアプリ)を考慮して以下のように選択してみてはいかがでしょうか?

パターン 1: Hono RPC を利用する

Hono RPC はサーバーサイドで export した型とルーティング情報をフロントエンドでインポートすることで、型推論が強く効く API クライアントを生成し RPC のように API 実行ができる機能です。

https://hono.dev/docs/guides/rpc#rpc

フロントエンドとサーバーサイドを同じチームで開発する場合、どちらも Hono を採用する意思決定が容易にできたり、フロントエンドとサーバーサイドの依存関係が深くても問題ないケースが多く、この選択肢を取るのが一番効率が良いと考えました。

Hono RPC を利用する場合、スキーマとエンドポイントは RPC の仕組みで共有できるので、場合によっては OpenAPI 生成も不要になる可能性があります。
Hono のエコシステムだけで完結でき、この方法が取れる場合は第1の選択肢として検討してみてはいかがでしょうか。

パターン2: OpenAPI のエコシステムで API クライアントを生成する

サーバーサイドとフロントエンドで開発チームが異なる場合、サーバーサイドとフロントエンドの依存度を下げた方が、一方の変更による影響を最小限に抑えることができます。
フロントエンドとサーバーサイドのスキーマ共有に OpenAPI を挟むことで、スキーマ共有はしつつ Hono RPC よりも疎結合にできます。

また OpenAPI のエコシステムを挟むことで、Web フロントエンド以外のクライアントに対しても、API クライアント生成機能を提供できます。

Web フロントエンドでは、openapi-fetchを利用したクライアント生成をこの後試してみます。

https://openapi-ts.dev/openapi-fetch/

モバイルアプリ開発では Flutter を用いることが多いのですが、OpenAPI から API クライアントを生成する Dart Package がいくつかあるようです。

https://www.memory-lovers.blog/entry/2024/03/09/075216

パターン3: 型情報を shared パッケージ経由でフロントエンドに提供

API クライアントの自動生成は非常に便利ですが、例えば「axios のインターセプターが使いたい」など、自動生成されるクライアントでは要件を満たせないケースがあります。
その場合は、Zod から生成した型情報を元にフロントエンドで API クライアントを自作することになります。

モノレポでこの方法をとる場合、パッケージ構成には注意が必要です。

サーバーサイドのパッケージに定義した型情報をフロントエンドのパッケージからインポートする場合、フロントエンド側で見えてはいけない情報を含んでいないか注意する必要があります。

オススメは型情報共有用のパッケージを別で切り出し、

root/
└── packages/
    ├── frontend/
    ├── server/
    └── shared/ # 型情報を定義するパッケージ

フロントエンドからサーバーサイドへの直接の依存を避けることです。

やってみた

ここまで検討した構成を簡単な TODO アプリを作成して、どのような実装になるのか検証しました。

コードの全量は以下に格納しているため、ブログでは特筆すべき点のみピックアップします。

https://github.com/joe-king-sh/hono-zod-openapi

Zod OpenAPI を利用した REST API の実装

サーバーサイドと shared パッケージのディレクトリ構成は以下です。

server/
├── package.json
└── src/
    ├── index.ts
    └── todos/
        ├── handlers.ts
        ├── index.ts
        ├── routes.ts
        └── storage.ts

shared/
├── package.json
├── tsconfig.json
└── src/
    ├── index.ts
    ├── schemas.ts
    └── types.ts

スキーマ定義

shared パッケージにスキーマ情報を定義します。
Zod で Union 型を定義した場合に生成される OpenAPI が確認したいため、TodoSchema を 1 と 2 に分けて定義しています。

この時 zod は@hono/zod-openapiからインポートしないとopenapi()メソッドが使えないので注意してください。

API の Example もここで設定可能です。後述のルーティング情報設定時にも Example を設定できますが、スキーマ定義時に設定しておいた方が、スキーマと Example のズレが発生しないのでオススメです。

shared/src/schemas.ts
import { z } from "@hono/zod-openapi";

export const TodoSchema1 = z.object({
	id: z.string().openapi({
		example: "10cb81aa-8807-46b8-809a-28aa7bede594",
	}),
	title: z.string().min(1, "タイトルは必須です").openapi({
		example: "人参を買う",
	}),
	completed: z.boolean().default(false).openapi({
		example: false,
	}),
	createdAt: z.string().datetime().openapi({
		example: "2021-01-01T00:00:00.000Z",
	}),
});

export const TodoSchema2 = z.object({
	id: z.string().openapi({
		example: "10cb81aa-8807-46b8-809a-28aa7bede594",
	}),
	title: z.string().min(1, "タイトルは必須です").openapi({
		example: "人参を買う",
	}),
	completed: z.boolean().default(false).openapi({
		example: false,
	}),
	createdAt: z.string().datetime().openapi({
		example: "2021-01-01T00:00:00.000Z",
	}),
	createdBy: z.string().openapi({
		example: "10cb81aa-8807-46b8-809a-28aa7bede594",
	}),
});

export const TodoSchema = z.union([TodoSchema1, TodoSchema2]);

// GET /todos - Response
export const GetTodosResponseSchema = z.array(TodoSchema);

// POST /todos - Request & Response
export const PostTodosRequestSchema = z.object({
	title: z.string().min(1, "Title is required").openapi({
		example: "ジャガイモを買う",
	}),
});

export const PostTodosResponseSchema = TodoSchema;

// PUT /todos/:id - Request & Response
export const PutTodosRequestSchema = z.object({
	title: z.string().min(1, "Title is required").optional().openapi({
		example: "ジャガイモを買う",
	}),
	completed: z.boolean().optional().openapi({
		example: true,
	}),
});

export const PutTodosResponseSchema = TodoSchema;

// DELETE /todos/:id - Request params
export const DeleteTodosParamsSchema = z.object({
	id: z.string(),
});

// Common params schema
export const TodoParamsSchema = z.object({
	id: z.string(),
});

ルーティング情報

createRoute()メソッドを利用して、todo モジュールの中でルーティング情報を定義します。

server/src/todos/routes.ts
import { createRoute } from "@hono/zod-openapi";
import {
	GetTodosResponseSchema,
	PostTodosRequestSchema,
	PostTodosResponseSchema,
	PutTodosRequestSchema,
	PutTodosResponseSchema,
	TodoParamsSchema,
} from "shared";

export const getTodosRoute = createRoute({
	method: "get",
	path: "/",
	responses: {
		200: {
			content: {
				"application/json": {
					schema: GetTodosResponseSchema,
				},
			},
			description: "List of todos",
		},
	},
});

export const createTodoRoute = createRoute({
	method: "post",
	path: "/",
	request: {
		body: {
			content: {
				"application/json": {
					schema: PostTodosRequestSchema,
				},
			},
		},
	},
	responses: {
		201: {
			content: {
				"application/json": {
					schema: PostTodosResponseSchema,
				},
			},
			description: "Created todo",
		},
		400: {
			description: "Invalid request body",
		},
	},
});

export const updateTodoRoute = createRoute({
	method: "put",
	path: "/{id}",
	request: {
		params: TodoParamsSchema,
		body: {
			content: {
				"application/json": {
					schema: PutTodosRequestSchema,
				},
			},
		},
	},
	responses: {
		200: {
			content: {
				"application/json": {
					schema: PutTodosResponseSchema,
				},
			},
			description: "Updated todo",
		},
		404: {
			description: "Todo not found",
		},
		400: {
			description: "Invalid request body",
		},
	},
});

export const deleteTodoRoute = createRoute({
	method: "delete",
	path: "/{id}",
	request: {
		params: TodoParamsSchema,
	},
	responses: {
		204: {
			description: "Todo deleted successfully",
		},
		404: {
			description: "Todo not found",
		},
	},
});

ハンドラーの実装

API のリクエストを処理するハンドラーを実装します。

ルーティングとハンドラーを別ファイルに定義する場合、Hono の Context に valid()した後の型情報が引き継がれないためRouteHandlerを利用して型付けする必要があります。

server/src/todos/handlers.ts
import type { RouteHandler } from "@hono/zod-openapi";
import type {
	createTodoRoute,
	deleteTodoRoute,
	getTodosRoute,
	updateTodoRoute,
} from "./routes";
import { todoStorage } from "./storage";

export const getTodos: RouteHandler<typeof getTodosRoute> = (c) => {
	const todos = todoStorage.getAll();
	return c.json(todos, 200);
};

export const createTodo: RouteHandler<typeof createTodoRoute> = (c) => {
	const body = c.req.valid("json");
	const id = crypto.randomUUID();
	const todo = {
		id,
		title: body.title,
		completed: false,
		createdAt: new Date().toISOString(),
	};

	const created = todoStorage.create(todo);
	return c.json(created, 201);
};

export const updateTodo: RouteHandler<typeof updateTodoRoute> = (c) => {
	const { id } = c.req.valid("param");
	const body = c.req.valid("json");

	const updated = todoStorage.update(id, body);

	if (!updated) {
		return c.json({ error: "Todo not found" }, 404);
	}

	return c.json(updated, 200);
};

export const deleteTodo: RouteHandler<typeof deleteTodoRoute> = (c) => {
	const { id } = c.req.valid("param");

	const deleted = todoStorage.delete(id);

	if (!deleted) {
		return c.json({ error: "Todo not found" }, 404);
	}

	return c.body(null, 204);
};

ルーティングとハンドラーの紐付け

別々のファイルに定義したルーティング情報とハンドラーを紐づけます。

server/src/todos/index.ts
import { OpenAPIHono } from "@hono/zod-openapi";
import { createTodo, deleteTodo, getTodos, updateTodo } from "./handlers";
import {
	createTodoRoute,
	deleteTodoRoute,
	getTodosRoute,
	updateTodoRoute,
} from "./routes";

export const todosRouter = new OpenAPIHono();

todosRouter.openapi(getTodosRoute, getTodos);
todosRouter.openapi(createTodoRoute, createTodo);
todosRouter.openapi(updateTodoRoute, updateTodo);
todosRouter.openapi(deleteTodoRoute, deleteTodo);

API のエントリーポイントの実装

/todosに先ほど定義したルーターをマウントします。

/docで OpenAPI の JSON を取得可能です。また、/ui/scalarで Swagger UI や Scalar で生成された API ドキュメントを参照できます。

server/src/index.ts
import { serve } from "@hono/node-server";
import { swaggerUI } from "@hono/swagger-ui";
import { OpenAPIHono } from "@hono/zod-openapi";
import { Scalar } from "@scalar/hono-api-reference";
import { todosRouter } from "./todos";

export const app = new OpenAPIHono();

app.route("/todos", todosRouter);

app.doc("/doc", {
	openapi: "3.0.0",
	info: {
		version: "1.0.0",
		title: "TODO API with Hono + Zod OpenAPI",
		description:
			"A simple TODO API demonstrating Hono with Zod OpenAPI integration",
	},
});

// Swagger UI
app.get("/ui", swaggerUI({ url: "/doc" }));

// Scalar
app.get("/scalar", Scalar({ url: "/doc" }));

const port = 3000;

serve({
	fetch: app.fetch,
	port,
});

console.log(`Server is running on port ${port}`);

生成される OpenAPI

長いので折りたたみますが、Zod で定義したスキーマがそのまま OpenAPI のスキーマになっていることが確認できます。

Zod で Union 型で定義した情報は OpenAPI ではanyOfとして定義されます。

生成されたOpenAPI
openapi: 3.0.0
info:
  version: 1.0.0
  title: TODO API with Hono + Zod OpenAPI
  description: A simple TODO API demonstrating Hono with Zod OpenAPI integration

components:
  schemas: {}
  parameters: {}

paths:
  /todos:
    get:
      responses:
        "200":
          description: List of todos
          content:
            application/json:
              schema:
                type: array
                items:
                  anyOf:
                    - type: object
                      properties:
                        id:
                          type: string
                          example: "10cb81aa-8807-46b8-809a-28aa7bede594"
                        title:
                          type: string
                          minLength: 1
                          example: "人参を買う"
                        completed:
                          type: boolean
                          default: false
                          example: false
                        createdAt:
                          type: string
                          format: date-time
                          example: "2021-01-01T00:00:00.000Z"
                      required:
                        - id
                        - title
                        - createdAt
                    - type: object
                      properties:
                        id:
                          type: string
                          example: "10cb81aa-8807-46b8-809a-28aa7bede594"
                        title:
                          type: string
                          minLength: 1
                          example: "人参を買う"
                        completed:
                          type: boolean
                          default: false
                          example: false
                        createdAt:
                          type: string
                          format: date-time
                          example: "2021-01-01T00:00:00.000Z"
                        createdBy:
                          type: string
                          example: "10cb81aa-8807-46b8-809a-28aa7bede594"
                      required:
                        - id
                        - title
                        - createdAt
                        - createdBy
    post:
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  minLength: 1
                  example: "ジャガイモを買う"
              required:
                - title
      responses:
        "201":
          description: Created todo
          content:
            application/json:
              schema:
                anyOf:
                  - type: object
                    properties:
                      id:
                        type: string
                        example: "10cb81aa-8807-46b8-809a-28aa7bede594"
                      title:
                        type: string
                        minLength: 1
                        example: "人参を買う"
                      completed:
                        type: boolean
                        default: false
                        example: false
                      createdAt:
                        type: string
                        format: date-time
                        example: "2021-01-01T00:00:00.000Z"
                    required:
                      - id
                      - title
                      - createdAt
                  - type: object
                    properties:
                      id:
                        type: string
                        example: "10cb81aa-8807-46b8-809a-28aa7bede594"
                      title:
                        type: string
                        minLength: 1
                        example: "人参を買う"
                      completed:
                        type: boolean
                        default: false
                        example: false
                      createdAt:
                        type: string
                        format: date-time
                        example: "2021-01-01T00:00:00.000Z"
                      createdBy:
                        type: string
                        example: "10cb81aa-8807-46b8-809a-28aa7bede594"
                    required:
                      - id
                      - title
                      - createdAt
                      - createdBy
        "400":
          description: Invalid request body

  /todos/{id}:
    put:
      parameters:
        - schema:
            type: string
          required: true
          name: id
          in: path
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                title:
                  type: string
                  minLength: 1
                  example: "ジャガイモを買う"
                completed:
                  type: boolean
                  example: true
      responses:
        "200":
          description: Updated todo
          content:
            application/json:
              schema:
                anyOf:
                  - type: object
                    properties:
                      id:
                        type: string
                        example: "10cb81aa-8807-46b8-809a-28aa7bede594"
                      title:
                        type: string
                        minLength: 1
                        example: "人参を買う"
                      completed:
                        type: boolean
                        default: false
                        example: false
                      createdAt:
                        type: string
                        format: date-time
                        example: "2021-01-01T00:00:00.000Z"
                    required:
                      - id
                      - title
                      - createdAt
                  - type: object
                    properties:
                      id:
                        type: string
                        example: "10cb81aa-8807-46b8-809a-28aa7bede594"
                      title:
                        type: string
                        minLength: 1
                        example: "人参を買う"
                      completed:
                        type: boolean
                        default: false
                        example: false
                      createdAt:
                        type: string
                        format: date-time
                        example: "2021-01-01T00:00:00.000Z"
                      createdBy:
                        type: string
                        example: "10cb81aa-8807-46b8-809a-28aa7bede594"
                    required:
                      - id
                      - title
                      - createdAt
                      - createdBy
        "400":
          description: Invalid request body
        "404":
          description: Todo not found
    delete:
      parameters:
        - schema:
            type: string
          required: true
          name: id
          in: path
      responses:
        "204":
          description: Todo deleted successfully
        "404":
          description: Todo not found

レスポンスのバリデーションをかける

レスポンスのバリデーションは@hono/zod-openapiでは提供されていません。
返却する前にレスポンススキーマを用いて Parse することで、バリデーションを自前でかけられます。

全てのハンドラーで同じ処理が必要になるため、実案件ではミドルウェア化を検討します。

server/src/todos/handlers.ts
export const getTodos: RouteHandler<typeof getTodosRoute> = (c) => {
	const todos = todoStorage.getAll();

	// レスポンスバリデーション
	const result = GetTodosResponseSchema.safeParse(todos);
	if (!result.success) {
		console.error("Response validation error:", result.error);
		throw new Error("レスポンスデータのバリデーションに失敗しました");
	}

	return c.json(result.data, 200);
};

Hono RPC を利用した API 呼び出し

server パッケージから export したAppTypeを利用して、API クライアントを作成すると、client.todos.$get()のように TODO 取得のリモートプロシージャを実行できます。

web/src/api/hono-rpc-client.ts
import { hc } from "hono/client";
import type { AppType } from "server";
import type { Todo } from "shared";
import type { TodoApiClient } from "./types";
import { ApiError } from "./types";

export class HonoRpcClient implements TodoApiClient {
	private client: ReturnType<typeof hc<AppType>>;

	constructor(baseUrl = "/api") {
		this.client = hc<AppType>(baseUrl);
	}

	async getTodos(): Promise<Todo[]> {
		try {
			const response = await this.client.todos.$get();
			if (!response.ok) {
				throw new ApiError(
					`Failed to fetch todos: ${response.status}`,
					response.status
				);
			}
			return await response.json();
		} catch (error) {
			if (error instanceof ApiError) throw error;
			throw new ApiError("Network error", 0, error);
		}
	}

	async createTodo(title: string): Promise<Todo> {
		try {
			const response = await this.client.todos.$post({
				json: { title },
			});
			if (!response.ok) {
				throw new ApiError(
					`Failed to create todo: ${response.status}`,
					response.status
				);
			}
			return await response.json();
		} catch (error) {
			if (error instanceof ApiError) throw error;
			throw new ApiError("Network error", 0, error);
		}
	}

	async updateTodo(
		id: string,
		data: { title?: string; completed?: boolean }
	): Promise<Todo> {
		try {
			const response = await this.client.todos[":id"].$put({
				param: { id },
				json: data,
			});
			if (!response.ok) {
				throw new ApiError(
					`Failed to update todo: ${response.status}`,
					response.status
				);
			}
			return await response.json();
		} catch (error) {
			if (error instanceof ApiError) throw error;
			throw new ApiError("Network error", 0, error);
		}
	}

	async deleteTodo(id: string): Promise<void> {
		try {
			const response = await this.client.todos[":id"].$delete({
				param: { id },
			});
			if (!response.ok) {
				throw new ApiError(
					`Failed to delete todo: ${response.status}`,
					response.status
				);
			}
		} catch (error) {
			if (error instanceof ApiError) throw error;
			throw new ApiError("Network error", 0, error);
		}
	}
}

Hono だけで完結してかなり楽ですね。

openapi-fetch を利用した API 呼び出し

openapi-fetch を利用する場合、まず openapi-typescript を利用して API の型情報を生成します。

npx openapi-typescript https://localhost:3000/doc -o ./shared/src/generated/types.ts

生成した型情報を利用して openapi-fetch で API クライアントを作成します。

web/src/api/openapi-fetch-client.ts
import createClient from "openapi-fetch";
import type { Todo } from "shared";
import type { paths } from "../generated/api-types";
import type { TodoApiClient } from "./types";
import { ApiError } from "./types";

export class OpenApiFetchClient implements TodoApiClient {
	private client: ReturnType<typeof createClient<paths>>;

	constructor(baseUrl: string = "/api") {
		this.client = createClient<paths>({ baseUrl });
	}

	async getTodos(): Promise<Todo[]> {
		try {
			const { data, error } = await this.client.GET("/todos");

			if (error) {
				throw new ApiError(`Failed to get todos: ${error}`, 500, error);
			}

			return data || [];
		} catch (error) {
			if (error instanceof ApiError) {
				throw error;
			}
			throw new ApiError("Failed to get todos", 500, error);
		}
	}

	async createTodo(title: string): Promise<Todo> {
		try {
			const { data, error } = await this.client.POST("/todos", {
				body: { title },
			});

			if (error) {
				throw new ApiError(`Failed to create todo: ${error}`, 400, error);
			}

			if (!data) {
				throw new ApiError("No data returned from create todo", 500);
			}

			return data;
		} catch (error) {
			if (error instanceof ApiError) {
				throw error;
			}
			throw new ApiError("Failed to create todo", 500, error);
		}
	}

	async updateTodo(
		id: string,
		updateData: { title?: string; completed?: boolean }
	): Promise<Todo> {
		try {
			const { data, error } = await this.client.PUT("/todos/{id}", {
				params: { path: { id } },
				body: updateData,
			});

			if (error) {
				throw new ApiError(`Failed to update todo: ${error}`, 400, error);
			}

			if (!data) {
				throw new ApiError("No data returned from update todo", 500);
			}

			return data;
		} catch (error) {
			if (error instanceof ApiError) {
				throw error;
			}
			throw new ApiError("Failed to update todo", 500, error);
		}
	}

	async deleteTodo(id: string): Promise<void> {
		try {
			const { error } = await this.client.DELETE("/todos/{id}", {
				params: { path: { id } },
			});

			if (error) {
				throw new ApiError(`Failed to delete todo: ${error}`, 400, error);
			}
		} catch (error) {
			if (error instanceof ApiError) {
				throw error;
			}
			throw new ApiError("Failed to delete todo", 500, error);
		}
	}
}

Hono RPC よりも一手間かかりますが、Express の構成も OpenAPI から型生成しているので、問題になることはなさそうです。

API クライアントを自作した API 呼び出し

shared の下に型情報を吐いているので、これを元に API クライアントを自作します。
API クライアント側でもレスポンスの検証が行いたい場合はschemas.tsを利用して zod でバリデーションを実装可能です。

shared/src/types.ts
import type { z } from "zod";
import type {
	DeleteTodosParamsSchema,
	GetTodosResponseSchema,
	PostTodosRequestSchema,
	PostTodosResponseSchema,
	PutTodosRequestSchema,
	PutTodosResponseSchema,
	TodoParamsSchema,
	TodoSchema,
} from "./schemas";

export type Todo = z.infer<typeof TodoSchema>;

// API Request/Response types
export type GetTodosResponse = z.infer<typeof GetTodosResponseSchema>;

export type PostTodosRequest = z.infer<typeof PostTodosRequestSchema>;
export type PostTodosResponse = z.infer<typeof PostTodosResponseSchema>;

export type PutTodosRequest = z.infer<typeof PutTodosRequestSchema>;
export type PutTodosResponse = z.infer<typeof PutTodosResponseSchema>;

export type DeleteTodosParams = z.infer<typeof DeleteTodosParamsSchema>;
export type TodoParams = z.infer<typeof TodoParamsSchema>;

クライアントの実装は割愛します。

まとめ

サーバーサイド TypeScript で REST API を作成するにあたり、型安全で開発生産性向上を狙ったスキーマ駆動の技術選定を検討してみました。

サーバーサイドの結論としては Zod を中心にして Hono や OpenAPI のエコシステムをうまく組み合わせるのが良さそうです。
次に開発チームの構成や、何から呼ばれるかを意識してクライアントへの API 仕様の共有方法をご検討ください。

最後に

osawa yutoさん、やまたつさん、高橋ゆうきさん

Hono を利用したスキーマ駆動開発について諸々情報提供やディスカッションありがとうございました!

この記事がどなたかの役に立つと幸いです。

以上。リテールアプリ共創部のきんじょーでした。

この記事をシェアする

facebookのロゴhatenaのロゴtwitterのロゴ

© Classmethod, Inc. All rights reserved.