---
title: "ZodからOpenAPIを生成する方法｜z.toJSONSchemaとzod-to-openapi・Hono統合の出力差を実測【2026年10月】"
url: "https://www.issoh.co.jp/tech/details/5646/"
published: 2025-03-04
updated: 2026-10-30
categories: ["TypeScript"]
publisher: "株式会社一創"
---

# ZodからOpenAPIを生成する方法｜z.toJSONSchemaとzod-to-openapi・Hono統合の出力差を実測【2026年10月】

ZodスキーマからOpenAPIの仕様書を出す手段は、2026年10月時点で大きく3つあります。Zod本体の`z.toJSONSchema()`、パスまで組める`@asteasolutions/zod-to-openapi`や`zod-openapi`、そしてHono・Fastifyのルート定義と一体になった統合パッケージです。本記事では zod 4.6.7・@asteasolutions/zod-to-openapi 9.1.0・zod-openapi 6.0.2・@hono/zod-openapi 1.6.3 を Node.js 26.5.0 で実際に動かし、出力されたJSONをもとに違いと落とし穴を整理します。

## まとめ：ZodからOpenAPIを出す手段の選び方

- **スキーマ単体をOpenAPI 3.0形式にするだけなら**、ライブラリ不要。`z.toJSONSchema(schema, { target: "openapi-3.0" })`で`nullable: true`形式が出る。OpenAPI 3.1なら既定（Draft 2020-12）のままでよい。
- **パス・パラメータ・レスポンスまで含む仕様書**は`@asteasolutions/zod-to-openapi` 9.1.0（3.0／3.1／3.2のジェネレーターあり）か、`.meta()`だけで書ける`zod-openapi` 6.0.2。
- **Honoを使っているなら`@hono/zod-openapi`**。検証と仕様書が同じ定義から出る。ただし検証失敗は既定で400とZodErrorの中身をそのまま返し、ボディは`required: true`を付けないと空でも通る。
- **逆方向（OpenAPI→Zod）は別のツール**。`openapi-zod-client`はnpmの最終公開が2025年2月で依存がZod 3のまま。Zod 4なら`typed-openapi`の`--runtime zod`などを検討する。

## zod-to-openapiの役割：Zodを起点とする検証・型・仕様書の一元管理

[Zod](/tech/details/7391/)はTypeScriptのスキーマ検証ライブラリで、`z.object()`で書いた定義から実行時の検証と型（`z.infer`）を同時に得られます。zod-to-openapi系のツールは、同じ定義を[OpenAPIの仕様書](/tech/details/10408/)にも書き出します。検証・型・仕様書を別々に書くと、項目を1つ足すたびに3箇所を直すことになり、仕様書だけ古いまま残りがちです。Zodを起点にし、ビルドやCIで生成処理を実行すれば、スキーマ変更を仕様書に反映できます。

### Zod→OpenAPIとOpenAPI→Zodの向き

「openapi zod」「openapi to zod」のように語順だけ違う検索が混ざりますが、作業は逆です。自分のサーバーのZodから仕様書を出すのが**Zod→OpenAPI**（本記事の主題）。外部APIなど既にある仕様書からZodのスキーマやクライアントを起こすのが**OpenAPI→Zod**で、使うパッケージがまったく違います。後者は記事の後半で扱います。仕様書から各言語のコードを生成するのは[OpenAPI Generator](/tech/details/3353/)の役割で、これとも区別が要ります。

## Zod 4標準のz.toJSONSchemaによるOpenAPIスキーマ生成

Zod 4からは[z.toJSONSchema()](https://zod.dev/json-schema)が本体に入りました。以前よく使われた`zod-to-json-schema`は、READMEで「2025年11月以降は積極的にメンテナンスしない」と告知し、Zod 4標準への移行を勧めています。

### target:”openapi-3.0″とOpenAPI 3.1の指定

`target`に指定できるのは`draft-2020-12`（既定）・`draft-07`・`draft-04`・`openapi-3.0`の4つです。違いが最も出るのは`nullable()`で、同じスキーマでも出力が変わります。

```
import * as z from "zod";

const User = z.object({
  id: z.uuid().meta({ description: "ユーザーID" }),
  name: z.string().nullable(),
  role: z.enum(["admin", "member"]).default("member"),
});

z.toJSONSchema(User, { target: "openapi-3.0" });
// name: { "nullable": true, "type": "string" }

z.toJSONSchema(User);
// name: { "type": ["string", "null"] }  ＋ "$schema": "https://json-schema.org/draft/2020-12/schema"
```

OpenAPI 3.1の既定の方言はJSON Schema Draft 2020-12にOAS固有の語彙を加えたもので、Draft 2020-12のスキーマも利用できるため、3.1で使うなら`target`を省略します。注意したいのは、`target`の型が任意の文字列を受け付ける点です（型定義は`({} & string)`を含む）。`target: "openapi-3.1"`と書いてもエラーにならず、実測では`$schema`が付かず、再利用スキーマの参照先が`#/definitions/…`になる中途半端な出力になりました。3.1のつもりで存在しない値を書かないことです。

### io:”input”と”output”によるrequiredと追加プロパティの差

既定の`io: "output"`は、パース後の値の形を出力します。上の`role`は`.default()`付きなのでパース後は必ず値があり、`required`は`["id","name","role"]`、さらに`additionalProperties: false`が付きました。`io: "input"`にすると`required`は`["id","name"]`に減り、`additionalProperties`も出ません。リクエストボディの仕様には`input`、レスポンスには`output`を使うのが筋です。`.transform()`を含むスキーマは`output`では「Transforms cannot be represented in JSON Schema」で例外になり、`input`なら変換前の型（例では`{"type":"string"}`）が出ます。

### .meta()によるdescriptionとexampleの付与

`.meta()`に渡した`description`や`example`は、そのままスキーマのキーとして出力されます。上の例の`id`には`"description":"ユーザーID"`が入りました。あわせて、`z.uuid()`は`format: "uuid"`に加えてバリアント桁まで検査する長い`pattern`を、`.int()`は`minimum: -9007199254740991`と`maximum: 9007199254740991`を自動で出します。仕様書を人が読む前提なら、この長さを許容できるかは事前に見ておくべきです。

### components.schemasへの配置に合わせた$refの設定

同じスキーマに`.meta({ id: "Addr" })`を付けて複数箇所で使うと、`z.toJSONSchema()`はそれを`$defs`（OpenAPI 3.0形式では`definitions`）に括り出し、`#/definitions/Addr`のような参照を作ります。この例のスキーマをcomponents.schemasに配置する場合の参照先は`#/components/schemas/…`なので、このままでは使えません。レジストリにidを付けて登録し、`uri`で参照先を書き換えます。

```
const reg = z.registry();
const Addr = z.object({ city: z.string() });
reg.add(Addr, { id: "Addr" });
const User = z.object({ name: z.string(), home: Addr });
reg.add(User, { id: "User" });

z.toJSONSchema(reg, {
  target: "openapi-3.0",
  uri: (id) => `#/components/schemas/${id}`,
});
// { schemas: { Addr: {...}, User: { properties: { home: { "$ref": "#/components/schemas/Addr" } } } } }
```

出力の`schemas`をそのまま`components.schemas`に差し込めます。ただし各スキーマに`"$id"`が残り、`.optional()`を付けた参照は`allOf`で包まれました。OpenAPI 3.0の検証ツールに通す前に`$id`は取り除いてください。パスやレスポンスまで手で組む量が増えてきたら、次章のライブラリに任せる方が早くなります。

### Dateやtransformなど変換できない型

`z.date()`・`z.bigint()`・`z.map()`・`z.set()`・`z.transform()`などはJSON Schemaに対応する表現がなく、既定では例外になります（`z.date()`は「Date cannot be represented in JSON Schema」）。`unrepresentable: "any"`にすると例外は止まりますが、出力は空の`{}`です。日付を文字列として仕様に載せたいなら、`override`で中身を書きます。

```
z.toJSONSchema(z.object({ createdAt: z.date() }), {
  target: "openapi-3.0",
  unrepresentable: "any",
  override: (ctx) => {
    if (ctx.zodSchema._zod.def.type === "date") {
      ctx.jsonSchema.type = "string";
      ctx.jsonSchema.format = "date-time";
    }
  },
});
```

APIの入出力がJSONである以上、最初から`z.iso.datetime()`で文字列として定義しておく方が、仕様と実装のずれは生まれません。

## パス付きの仕様書を組む：@asteasolutions/zod-to-openapiとzod-openapi

パス・パラメータ・レスポンス・`components`をまとめた仕様書を作るなら、専用ライブラリが組み立てを肩代わりします。2026年10月30日にnpmレジストリで確認した版は次のとおりです。

| パッケージ                          | 最新版   | 公開日        | peer zod             | メタの付け方               |
| ------------------------------ | ----- | ---------- | -------------------- | -------------------- |
| @asteasolutions/zod-to-openapi | 9.1.0 | 2026-07-19 | ^4.0.0               | .openapi() / .meta() |
| zod-openapi                    | 6.0.2 | 2026-08-31 | ^4.0.0               | .meta()              |
| @hono/zod-openapi              | 1.6.3 | 2026-09-04 | ^4.0.0               | .openapi()           |
| fastify-zod-openapi            | 5.7.0 | 2026-07-20 | ^3.25.74 \|\| ^4.0.0 | .meta()              |

### @asteasolutions/zod-to-openapi 9.1.0：registryと3つのジェネレーター

[@asteasolutions/zod-to-openapi](https://github.com/asteasolutions/zod-to-openapi)は、スキーマとパスを`OpenAPIRegistry`に登録し、ジェネレーターで仕様書を出す方式です。`.openapi()`メソッドは`extendZodWithOpenApi(z)`を呼ぶまで存在せず、呼ばずに使うと実測で「z.string(…).openapi is not a function」になりました。エントリーポイントで1回だけ呼びます。

```
import * as z from "zod";
import { extendZodWithOpenApi, OpenAPIRegistry, OpenApiGeneratorV31 } from "@asteasolutions/zod-to-openapi";

extendZodWithOpenApi(z);
const registry = new OpenAPIRegistry();

const User = registry.register("User", z.object({
  id: z.string().openapi({ example: "u_1" }),
  name: z.string().nullable(),
}));

registry.registerPath({
  method: "get",
  path: "/users/{id}",
  request: { params: z.object({ id: z.string() }) },
  responses: { 200: { description: "OK", content: { "application/json": { schema: User } } } },
});

const doc = new OpenApiGeneratorV31(registry.definitions).generateDocument({
  openapi: "3.1.1",
  info: { title: "Users API", version: "1.0.0" },
});
```

ジェネレーターは`OpenApiGeneratorV3`（3.0.x）・`V31`（3.1.x）・`V32`（3.2.x）の3つで、`V32`は9.0.0（2026-07-14）で入りました。同じ`name`は、V3で`{"type":"string","nullable":true}`、V31とV32で`{"type":["string","null"]}`になります。V32はスキーマの出力が3.1と同じで、3.2で増えたメディアタイプの`itemSchema`（`text/event-stream`などのストリームの各要素を表す）を書けるようになります。SSEやJSON Linesで返すエンドポイントを持つなら、[OpenAPI 3.2](/tech/details/10408/)のV32を選ぶ理由になります。なおZod 3のプロジェクトは、READMEの案内どおり7.3.4に固定します（以降の版はpeerが`zod ^4.0.0`）。

### zod-openapi 6.0.2：.meta()だけで書けるcreateDocument

[zod-openapi](https://github.com/samchungy/zod-openapi)（作者samchungy）は、Zod標準の`.meta()`でメタデータを付け、`createDocument()`にOpenAPIオブジェクトの形のまま渡します。Zodへのメソッド追加が要りません。

```
import * as z from "zod";
import { createDocument } from "zod-openapi";

const User = z.object({ id: z.string(), name: z.string().nullable() }).meta({ id: "User" });

const doc = createDocument({
  openapi: "3.1.0",
  info: { title: "Users API", version: "1.0.0" },
  paths: {
    "/users/{id}": {
      get: {
        requestParams: { path: z.object({ id: z.string() }) },
        responses: { "200": { description: "OK", content: { "application/json": { schema: User } } } },
      },
    },
  },
});
```

`.meta({ id: "User" })`を付けたスキーマは自動で`components.schemas.User`に登録されました。READMEの対応表は3.1.0と3.1.1で、「3.1.0が最小」と書かれています。実測では`openapi: "3.0.3"`を渡しても`nullable: true`形式で出力されましたが、対応表の外なので、OpenAPI 3.0が必須の環境では@asteasolutionsのV3を選ぶ方が確実です。

## Hono・Fastify統合による検証と仕様書の定義共有

HonoやFastifyのルート定義にZodスキーマを組み込む構成なら、統合パッケージを使うことで、検証用と仕様書用の定義の重複を減らせます。

### @hono/zod-openapi 1.6.3：createRouteと検証失敗時の応答

[@hono/zod-openapi](https://hono.dev/examples/zod-openapi)は、1.6.3で内部の依存が`@asteasolutions/zod-to-openapi ^9.1.0`になり、peerは`hono >=4.10.0`です。`createRoute`でスキーマを宣言し、`app.openapi()`でハンドラを結ぶと、検証済みの値が`c.req.valid()`で型付きで取れます。`z`は拡張済みのものが同パッケージから出ているので、`extendZodWithOpenApi`は呼ばなくて構いません。

```
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";

const app = new OpenAPIHono({
  defaultHook: (result, c) => {
    if (!result.success) {
      return c.json({
        code: "VALIDATION_ERROR",
        issues: result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
      }, 422);
    }
  },
});

const route = createRoute({
  method: "post",
  path: "/users",
  request: {
    body: { content: { "application/json": { schema: z.object({ name: z.string().min(1) }) } }, required: true },
  },
  responses: {
    201: { description: "Created" },
    422: {
      description: "Validation error",
      content: { "application/json": { schema: z.object({
        code: z.literal("VALIDATION_ERROR"),
        issues: z.array(z.object({ path: z.string(), message: z.string() })),
      }) } },
    },
    400: { description: "Malformed JSON" },
    415: { description: "Unsupported Media Type" },
  },
});

app.openapi(route, (c) => c.body(null, 201));
app.doc("/doc", { openapi: "3.0.0", info: { title: "Users API", version: "1.0.0" } });
```

実測で押さえておきたい既定の挙動が2つあります。1つめに、`defaultHook`を付けないと検証失敗は**400で`{"success":false,"error":{"name":"ZodError",...}}`をそのまま返します**。検証エラーには項目名や制約値などが含まれるため、公開する情報を選び、上のように応答形式を整えます（上の例では422と自前の形式になりました）。2つめに、`body`の`required`は既定で`false`で、ボディ無しのPOSTは検証を通り、`c.req.valid("json")`が`{}`になってハンドラまで届きます。`required: true`を付け、defaultHookを設定しない別の検証では400で止まりました。上の掲載コードでは、Content-TypeもボディもないPOSTはdefaultHookにより422になります。壊れたJSONは400、`text/plain`で送ると415になり、これらはdefaultHookを通らないので、コードでは`responses`に422と並べて登録しています。仕様書は`app.doc()`が3.0、`app.doc31()`が3.1で出し、1.6.3の時点で3.2用のメソッドはありません。

### fastify-zod-openapi 5.7.0：zod-openapi 5系に依存する点

Fastify 5向けの[fastify-zod-openapi](https://www.npmjs.com/package/fastify-zod-openapi) 5.7.0は、peerが`fastify 5`・`@fastify/swagger ^9.0.0`、Zodは`^3.25.74 || ^4.0.0`を受け付けます。中身は前章のzod-openapiですが、依存は`zod-openapi ^5.4.0`で、単体の最新6.0.2とはメジャーが違います。同じプロジェクトでzod-openapi 6を直接入れると、2つの版が並んで入ります。Fastifyのルートとは別に`createDocument`を呼ぶ構成なら、版をそろえるまで直接の導入は見送るのが無難です。

## Zod 4以降のライブラリ要否：選び方の基準と避ける場面

判断の軸は「仕様書がどこで必要か」です。

- **スキーマ定義だけ欲しい**（他チームへ型を渡す、[JSON Schema](/tech/details/16289/)としてフォーム生成やLLMの構造化出力に渡す）なら、`z.toJSONSchema()`だけで足ります。依存を足す理由はありません。
- **パス付きの仕様書をSwagger UIやクライアント生成に回す**なら、@asteasolutionsかzod-openapiです。3.0が必須、または3.2の`itemSchema`を使うなら@asteasolutions、Zodを拡張したくない・`.meta()`に統一したいならzod-openapiを選びます。
- **サーバーをこれから書く**なら、HonoやFastifyの統合版でルートの検証と仕様書を同じ定義から組み立てると、定義の重複を減らせます。

避けたいのは、`z.toJSONSchema()`の出力をつなぎ合わせて仕様書を手作りし続ける構成です。前章で見たとおり`$ref`の付け替えや`$id`の除去を自分で抱えることになり、パスやレスポンスの定義を手で書き始めた時点でライブラリへ移す方が保守は軽くなります。逆に、統合版をフレームワーク抜きで仕様書生成だけに使うのも不向きです。[Standard Schema](/tech/details/11777/)経由でZod以外のスキーマも受けたいなら、OpenAPI生成はZod専用のこれらのライブラリでは賄えない点にも注意してください。社内の[API開発・システム連携](/service/system/api/)で仕様書の整備から相談したい場合は、設計段階からお手伝いしています。

## 逆方向の変換：OpenAPIからのZodスキーマ・クライアント生成

既存の仕様書からZodを起こす用途で名前がよく挙がる[openapi-zod-client](https://www.npmjs.com/package/openapi-zod-client)は、npmの最終公開が2025年2月10日の1.18.3で、依存は`zod ^3.19.1`と`@zodios/core`のままです。Zod 4で新しく始めるなら、同じ作者による[typed-openapi](https://github.com/astahmer/typed-openapi)（4.1.0）が`--runtime zod`でZod 4、`--runtime zod3`でZod 3のスキーマ付きクライアントを生成します。

コード生成を使わずにJSON SchemaからZodスキーマを作るだけなら、Zod 4.2.0（2025-12-15）以降の`z.fromJSONSchema()`もあります。実測では`required`から外した項目は省略可、型違い（`age: "x"`）は不合格と、期待どおりに働きました。ただし公式ドキュメントは「Experimental」と明記し、安定APIではないとしています。本番の検証に使うなら版を固定し、更新のたびに動作を確かめてください。

## よくある質問

### zod-to-openapiとzod-openapiの違いは？

作者も書き方も別のパッケージです。`@asteasolutions/zod-to-openapi`は`extendZodWithOpenApi`でZodに`.openapi()`を足し、registryとジェネレーターで出力します。`zod-openapi`はZod標準の`.meta()`と`createDocument()`で書き、Zodを拡張しません。OpenAPI 3.0や3.2が要るなら前者です。

### extendZodWithOpenApiは必要？

`@asteasolutions/zod-to-openapi`で`.openapi()`を使うなら必要で、呼ばないと`.openapi is not a function`になります。`@hono/zod-openapi`は拡張済みの`z`を提供しているので不要、`zod-openapi`は`.meta()`を使うので不要です。

### z.toJSONSchemaでOpenAPI 3.1のスキーマを出すには？

`target`を省略して既定のDraft 2020-12で出します。OpenAPI 3.1の既定の方言はDraft 2020-12にOAS固有の語彙を加えたもので、Draft 2020-12のスキーマも利用できます。`target: "openapi-3.1"`は存在しない値ですが、エラーにならず別の形式で出力されるので書かないでください。

### Zod 3のプロジェクトでも使える？

@asteasolutions/zod-to-openapiは7.3.4に固定すれば使えます。zod-openapi 6と@hono/zod-openapi 1.6.3はpeerが`zod ^4.0.0`です。fastify-zod-openapi 5.7.0は`^3.25.74`も受け付けます。Zod 3から4への移行手順は[Zodの使い方入門](/tech/details/7391/)で扱っています。

### @hono/zod-validatorとの違いは？

`@hono/zod-validator`はリクエストをZodで検証するミドルウェアで、仕様書は出しません。@hono/zod-openapi 1.6.3は内部でこれ（`^0.9.1`）を使い、検証に加えて`createRoute`の定義から仕様書を出します。仕様書が要らないなら`@hono/zod-validator`単体で足ります。

## 関連記事

- [Zodの使い方入門｜npm install zodとz.infer・safeParse【最新4.6.7・脆弱性修正】](/tech/details/7391/)
- [OpenAPIとは？Swaggerとの違い（仕様とツール）とYAMLの書き方・3.0〜3.2の差分](/tech/details/10408/)
- [JSON Schemaとは？JSONの構造を検証する書き方とDraft 2020-12の実装を実装者目線で解説【2026年版】](/tech/details/16289/)
- [OpenAPI Generatorとは？使い方・生成されるコード・Maven設定を実行例で解説](/tech/details/3353/)
- [API仕様書・設計書の書き方｜記載項目チェックリストとOpenAPIサンプル・テンプレート](/tech/details/4068/)

---

出典: [ZodからOpenAPIを生成する方法｜z.toJSONSchemaとzod-to-openapi・Hono統合の出力差を実測【2026年10月】](<https://www.issoh.co.jp/tech/details/5646/>)（株式会社一創）
