Hono + Zod OpenAPI でスキーマ駆動の型安全 API を組む

@hono/zod-openapi を使うと、Zod スキーマ 1 本から OpenAPI 仕様・Swagger UI・ハンドラの型・クライアント SDK までまとめて派生させられます。Cloudflare Workers 上で動かす前提で、最小構成と本番運用で踏むポイントをまとめます。

API ドキュメントは「書いた瞬間に古くなる」のが宿命です。OpenAPI YAML を手で書くと実装と二重管理になり、レスポンスのフィールド名が一個ズレた瞬間にクライアントが壊れる。@hono/zod-openapiZod スキーマを真実の源にして、OpenAPI 仕様 / Swagger UI / ハンドラの入出力型を一気に派生させてくれるので、この二重管理問題が消えます。

本記事では Cloudflare Workers 上で動かす前提で、最小の動くコードと本番投入時の実用パターンをまとめます。

到達点と前提

作るもの: GET /tasks/:id / POST /tasks の最小 API。OpenAPI 仕様と Swagger UI が同じ Worker から配信されます。

検証バージョン (2026-04 時点):

  • Hono v4.12 / @hono/zod-openapi v0.21
  • @hono/swagger-ui v0.5 / Zod v4.3
  • Wrangler v4.84 / Cloudflare Workers

リクエストの流れ:

client ─▶ Worker
           ├─ /tasks/:id  ── Zod 検証 ──▶ ハンドラ
           ├─ /doc        ── OpenAPI JSON
           └─ /ui         ── Swagger UI (CDN 配信)

1. スキーマを 1 ファイルに集約する

@hono/zod-openapi は Zod スキーマに .openapi() を生やすことで、OpenAPI のメタデータを後付けできます。型と仕様を分離しないのが最大の利点です。

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

export const TaskSchema = z
  .object({
    id: z.string().uuid().openapi({ example: "0f5a..." }),
    title: z.string().min(1).max(200).openapi({ example: "ブログ書く" }),
    done: z.boolean().openapi({ example: false }),
    createdAt: z.string().datetime(),
  })
  .openapi("Task");

export const CreateTaskInput = z
  .object({
    title: z.string().min(1).max(200),
  })
  .openapi("CreateTaskInput");

export const ErrorSchema = z
  .object({
    code: z.string().openapi({ example: "validation_error" }),
    message: z.string(),
  })
  .openapi("Error");

.openapi("Task") のように名前を付けたスキーマは、OpenAPI 出力で components/schemas に登録され、$ref で参照されます。同じスキーマがレスポンス body として複数のエンドポイントから参照されるとき、巨大な inline 定義の重複が消えます。

2. ルート定義を createRoute で書く

ハンドラと OpenAPI 仕様を別々に書かず、createRoute 1 つにまとめます。

// src/routes/tasks.ts
import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
import { z } from "@hono/zod-openapi";
import { CreateTaskInput, TaskSchema, ErrorSchema } from "../schema";

const getTask = createRoute({
  method: "get",
  path: "/tasks/{id}",
  request: {
    params: z.object({
      id: z.string().uuid().openapi({ param: { name: "id", in: "path" } }),
    }),
  },
  responses: {
    200: {
      content: { "application/json": { schema: TaskSchema } },
      description: "task found",
    },
    404: {
      content: { "application/json": { schema: ErrorSchema } },
      description: "task not found",
    },
  },
  tags: ["tasks"],
});

const createTask = createRoute({
  method: "post",
  path: "/tasks",
  request: {
    body: {
      content: { "application/json": { schema: CreateTaskInput } },
      required: true,
    },
  },
  responses: {
    201: {
      content: { "application/json": { schema: TaskSchema } },
      description: "task created",
    },
    400: {
      content: { "application/json": { schema: ErrorSchema } },
      description: "invalid input",
    },
  },
  tags: ["tasks"],
});

export const tasks = new OpenAPIHono()
  .openapi(getTask, async (c) => {
    const { id } = c.req.valid("param"); // Zod で検証済 / 型推論済
    const row = await c.env.DB.prepare("SELECT * FROM tasks WHERE id = ?")
      .bind(id)
      .first();
    if (!row) return c.json({ code: "not_found", message: "task not found" }, 404);
    return c.json(row as any, 200); // 実運用では Drizzle で型を通す
  })
  .openapi(createTask, async (c) => {
    const input = c.req.valid("json"); // CreateTaskInput が推論される
    const id = crypto.randomUUID();
    const createdAt = new Date().toISOString();
    await c.env.DB.prepare(
      "INSERT INTO tasks (id, title, done, created_at) VALUES (?, ?, 0, ?)"
    )
      .bind(id, input.title, createdAt)
      .run();
    return c.json(
      { id, title: input.title, done: false, createdAt },
      201
    );
  });

c.req.valid("param" | "query" | "json")createRoute で宣言したスキーマから直接型推論されるので、ハンドラ内で as キャストが消えます。ここが他の OpenAPI ライブラリと比べて飛び抜けて気持ち良いポイントです。

3. OpenAPI JSON と Swagger UI を生やす

ルート登録が終わったら、.doc()swaggerUI() を 1 行ずつ足すだけで仕様と UI が出ます。

// src/index.ts
import { OpenAPIHono } from "@hono/zod-openapi";
import { swaggerUI } from "@hono/swagger-ui";
import { tasks } from "./routes/tasks";

type Bindings = { DB: D1Database };

const app = new OpenAPIHono<{ Bindings: Bindings }>();

app.route("/", tasks);

app.doc("/doc", {
  openapi: "3.1.0",
  info: { title: "r43lab tasks", version: "1.0.0" },
});

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

export default app;

/doc で OpenAPI 3.1 JSON、/ui で Swagger UI が見られます。Cloudflare Workers の場合は wrangler dev で起動してブラウザを開けばすぐ確認できます。

4. クライアント SDK を 1 コマンドで生成する

OpenAPI が生えてしまえば、クライアント側の型付き fetch ラッパーは生成器に任せられます。openapi-typescript で型だけ取り出し、openapi-fetch で叩く構成が、依存もスリムでおすすめです。

npx openapi-typescript http://localhost:8787/doc \
  -o packages/client/src/types.ts
// packages/client/src/index.ts
import createClient from "openapi-fetch";
import type { paths } from "./types";

export const api = createClient<paths>({ baseUrl: "https://api.r43lab.com" });

const { data, error } = await api.POST("/tasks", {
  body: { title: "ブログ書く" }, // ← 型補完が効く
});

サーバ側の Zod スキーマを 1 行変えると、ビルド時にクライアントの型まで赤くなるので、API の breaking change に気付かないまま release タグを打つ事故がほぼ消えます。

5. バリデーションエラーの整形を中央集約

デフォルトの defaultHook は何もしないので、Zod のエラーが 400 で返るときの形を揃えておきます。

// src/index.ts (抜粋)
const app = new OpenAPIHono<{ Bindings: Bindings }>({
  defaultHook: (result, c) => {
    if (!result.success) {
      return c.json(
        {
          code: "validation_error",
          message: "invalid input",
          issues: result.error.issues,
        },
        400
      );
    }
  },
});

これで全ルートの 400 が同じ JSON 形になり、クライアント側のエラーハンドラが 1 つで済みます。

落とし穴 / ハマりポイント

  • .openapi(name) を付け忘れると inline で展開される。生成 SDK がやたら冗長になり diff が荒れます。「再利用される型」には必ず名前を付ける
  • z.coerce を使うと OpenAPI 仕様の type が崩れる。クエリの number 化などは z.coerce.number() ではなく z.string().regex(/^\d+$/).transform(Number) のように、入力型を明示してから transform するほうが OpenAPI 出力が安定する
  • Workers の Response.json() と Hono の c.json() は別物c.json(x, 200) のステータスコードは createRoute の responses キーと一致していないとビルドが通らない (これは型エラーで気付ける、地味に有難い)
  • Swagger UI は CDN 経由で JS を引く。CSP を厳しくしている場合は script-srccdn.jsdelivr.net を許可するか、Scalar などローカルバンドル版に切り替える

ざっくり比較: 他の選択肢

OpenAPI を Hono に乗せる方法は複数あります。2026-04 時点の選択肢を一行で:

  • @hono/zod-openapi — 本記事の構成。Zod スキーマと createRoute を直結。型推論が一番きれい
  • TS ファースト + tsoa — クラスデコレータベース。Workers と相性が悪く、エッジに持っていく場合は外れる
  • 手書き YAML + @hono/swagger-ui — 仕様の自由度は最大だが二重管理が発生

「エッジで動く」「型推論が要る」の両方を満たすなら、現状 @hono/zod-openapi 一択と言って良いです。

まとめ

Zod スキーマ 1 本から OpenAPI / 実装の入力検証 / ハンドラの型 / クライアント SDK まで派生する、というのは口で言うほど自明ではありません。@hono/zod-openapi はその 4 点を自然に揃えてくれるので、API 周りの「実装と仕様がズレる」事故が構造的に消えます。Cloudflare Workers の薄い API なら、初期の 30 分でフル装備を組めます。

参考