以下の内容はhttps://bufferings.hatenablog.com/entry/2026/01/12/233317より取得しました。


Zod OpenAPI Honoで.openapi()を使わずにスキーマ定義を書きたい!

と思ってたらふつうにできた。

どういうこと?

Honoでサーバーサイドアプリケーションを書くときに、ミドルウェアとしてZod OpenAPI Honoを使うと、ZodのスキーマからOpenAPIのスキーマが生成できて便利。

例えばこういうZodのスキーマを用意して

import { z } from "@hono/zod-openapi";

export const CreateUserInput = z
  .object({
    name: z.string().openapi({
      example: "John Doe",
    }),
    age: z.number().openapi({
      example: 42,
    }),
  })
  .openapi({ description: "Create user input" });

export const CreateUserOutput = z
  .object({
    id: z.string().openapi({
      example: "123",
    }),
    name: z.string().openapi({
      example: "John Doe",
    }),
    age: z.number().openapi({
      example: 42,
    }),
    createdAt: z.string().openapi({
      example: "2024-01-01T00:00:00Z",
    }),
  })
  .openapi({ description: "Create user output" });

こんな風に、ルートを定義して使うと

import { createRoute } from "@hono/zod-openapi";

const createUserRoute = createRoute({
  method: "post",
  path: "/users",
  request: {
    body: {
      content: {
        "application/json": {
          schema: CreateUserInput,
        },
      },
    },
  },
  responses: {
    201: {
      content: {
        "application/json": {
          schema: CreateUserOutput,
        },
      },
      description: "User created",
    },
    400: {
      description: "Bad request",
    },
  },
});

こういうOpenAPI Specが生成されて便利

hono request src/app1/app.ts -P /doc | jq -r .body | jq .
{
  "openapi": "3.0.0",
  "info": {
    "version": "1.0.0",
    "title": "My API"
  },
  "components": {
    "schemas": {},
    "parameters": {}
  },
  "paths": {
    "/users": {
      "post": {
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "example": "John Doe"
                  },
                  "age": {
                    "type": "number",
                    "example": 42
                  }
                },
                "required": [
                  "name",
                  "age"
                ],
                "description": "Create user input"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "User created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string",
                      "example": "123"
                    },
                    "name": {
                      "type": "string",
                      "example": "John Doe"
                    },
                    "age": {
                      "type": "number",
                      "example": 42
                    },
                    "createdAt": {
                      "type": "string",
                      "example": "2024-01-01T00:00:00Z"
                    }
                  },
                  "required": [
                    "id",
                    "name",
                    "age",
                    "createdAt"
                  ],
                  "description": "Create user output"
                }
              }
            }
          },
          "400": {
            "description": "Bad request"
          }
        }
      }
    }
  }
}

/doc の情報を取得するのに Hono CLIを使ってみた。こういうときにサクッと使えて便利ね。ファイルを指定するときはそこからappをデフォルトエクスポートしておかなきゃいけないことが分かってなくてちょっと悩んだ。作りをよく考えたらそれはそう。

で、何が気になるの?

先に言っておくが、Zod OpenAPI Honoのつくりには全然問題はない。個人的に考えた構成の中で「スキーマはOpenAPIのことを知らないようにしたいな」と思った、というだけの話。

業務ロジックの入口として usecase.ts を考えたときに、ユースケースのInとOutのスキーマ定義を usecase.ts に書きたい。そして、HTTPのハンドリングを定義する handler.ts からそのZodのスキーマをimportしてOpenAPIのSpec生成に使いたい。

でも、このときに usecase.ts がOpenAPIのことを知らないほうが嬉しいなと思ったのだ。

usecase.ts は、こういう感じ↓

import { z } from "@hono/zod-openapi";
import { R } from "@praha/byethrow";

export const CreateUserInput = z
  .object({
    name: z.string().openapi({
      example: "John Doe",
    }),
    age: z.number().openapi({
      example: 42,
    }),
  })
  .openapi({ description: "Create user input" });

export const CreateUserOutput = z
  .object({
    id: z.string().openapi({
      example: "123",
    }),
    name: z.string().openapi({
      example: "John Doe",
    }),
    age: z.number().openapi({
      example: 42,
    }),
    createdAt: z.string().openapi({
      example: "2024-01-01T00:00:00Z",
    }),
  })
  .openapi({ description: "Create user output" });

export const createUser = (input: unknown) => {
  const parseResult = R.parse(CreateUserInput, input);

  if (R.isFailure(parseResult)) {
    return parseResult;
  }

  const data = parseResult.value;

  const user = {
    id: "123",
    name: data.name,
    age: data.age,
    createdAt: new Date().toISOString(),
  };

  return R.parse(CreateUserOutput, user);
};

handler.ts はこういう感じ↓

import { createRoute, OpenAPIHono } from "@hono/zod-openapi";
import { R } from "@praha/byethrow";
import { CreateUserInput, CreateUserOutput, createUser } from "./usecase.js";

const createUserRoute = createRoute({
  method: "post",
  path: "/users",
  request: {
    body: {
      content: {
        "application/json": {
          schema: CreateUserInput,
        },
      },
    },
  },
  responses: {
    201: {
      content: {
        "application/json": {
          schema: CreateUserOutput,
        },
      },
      description: "User created",
    },
    400: {
      description: "Bad request",
    },
  },
});

const app = new OpenAPIHono();

app.openapi(createUserRoute, async (c) => {
  const body = await c.req.json();
  const result = createUser(body);

  if (R.isFailure(result)) {
    return c.json({ error: result.error }, 400);
  }

  return c.json(result.value, 201);
});

export default app;

この usecase.ts がOpenAPIのことや @hono/zod-openapi のことを知ってるのが気になる点。つまり @hono/zod-openapi をimportしたり .openapi() を使ったりせずに、純粋なZodだけで完結できたらいいなと。

それできるよ

Claude Codeとかと話をしてたら、こういうことを教えてくれた

  • Zod OpenAPI Honoは Zod to OpenAPI を使っているよ
  • そしてZod to OpenAPIはZod v4の .meta() を使ったOpenAPIのSpec生成に対応しているよ

これまでは、Zod to OpenAPIがZodを拡張して .openapi() 関数を作って、それを使ってOpenAPIの付加情報をつけていたけど、Zod v4で新たに組み込まれた .meta() を使って同じことができるようになったということ。

そして、Zod OpenAPI Honoは裏側でZod to OpenAPIを使っているので、Zod v4の .meta() を使えるはず。

試してみた

ら、ふつうにできた。

import { z } from "zod";

export const CreateUserInput = z
  .object({
    name: z.string().meta({
      example: "John Doe",
    }),
    age: z.number().meta({
      example: 42,
    }),
  })
  .meta({
    description: "Create user input",
  });

export const CreateUserOutput = z
  .object({
    id: z.string().meta({
      example: "123",
    }),
    name: z.string().meta({
      example: "John Doe",
    }),
    age: z.number().meta({
      example: 42,
    }),
    createdAt: z.string().meta({
      example: "2024-01-01T00:00:00Z",
    }),
  })
  .meta({
    description: "Create user output",
  });

これだと usecase.ts がOpenAPIやHonoのことを知らなくて済むから嬉しい。

比較

こんな感じで比較して、.openapi() を使ったものと .meta() を使ったものが同一になることを確認しておいた。

#!/bin/bash
set -e

if diff <(hono request src/app1/app.ts -P /doc | jq -r .body | jq .) \
        <(hono request src/app2/app.ts -P /doc | jq -r .body | jq .); then
  echo "✅ Documents are identical!"
else
  echo "❌ Documents are different!"
  exit 1
fi

実行

❯ pnpm compare

> hello-zod4-openapi-hono@1.0.0 compare /Users/bufferings/Documents/hono/hello-zod4-openapi-hono
> bash scripts/compare.sh

✅ Documents are identical!

まぁ、ふつうにVitestとかでも書けると思うけど、Hono CLIを使って遊びたかったから。

悩ましいところ

components.schemas$ref は、正直どう扱うか悩む。.meta()id を定義すれば components.schemas として定義できるんだけど、それはOpenAPIの関心事だから usecase.ts に入れるのは変だなぁと思って。

それと、.meta() だと表現できない部分などもありそうではある。

今回の用途だと、components.schemas 定義をして使いまわしたいって気持ちにはあんまりならなさそうだから、とりあえず気にしなくていっか。

楽しかった

OpenAPIの部分以外を色々と楽しんでしまった。こんな感じ。Hono CLIとbyethrowは今回初めてちゃんと触ってみた。好きな感じ。

コードはここにおいといた




以上の内容はhttps://bufferings.hatenablog.com/entry/2026/01/12/233317より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14