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-openapi9.1.0(3.0/3.1/3.2のジェネレーターあり)か、.meta()だけで書けるzod-openapi6.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はTypeScriptのスキーマ検証ライブラリで、z.object()で書いた定義から実行時の検証と型(z.infer)を同時に得られます。zod-to-openapi系のツールは、同じ定義をOpenAPIの仕様書にも書き出します。検証・型・仕様書を別々に書くと、項目を1つ足すたびに3箇所を直すことになり、仕様書だけ古いまま残りがちです。Zodを起点にし、ビルドやCIで生成処理を実行すれば、スキーマ変更を仕様書に反映できます。
Zod→OpenAPIとOpenAPI→Zodの向き
「openapi zod」「openapi to zod」のように語順だけ違う検索が混ざりますが、作業は逆です。自分のサーバーのZodから仕様書を出すのがZod→OpenAPI(本記事の主題)。外部APIなど既にある仕様書からZodのスキーマやクライアントを起こすのがOpenAPI→Zodで、使うパッケージがまったく違います。後者は記事の後半で扱います。仕様書から各言語のコードを生成するのはOpenAPI Generatorの役割で、これとも区別が要ります。
Zod 4標準のz.toJSONSchemaによるOpenAPIスキーマ生成
Zod 4からはz.toJSONSchema()が本体に入りました。以前よく使われた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は、スキーマとパスを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のV32を選ぶ理由になります。なおZod 3のプロジェクトは、READMEの案内どおり7.3.4に固定します(以降の版はpeerがzod ^4.0.0)。
zod-openapi 6.0.2:.meta()だけで書けるcreateDocument
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は、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 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としてフォーム生成や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経由でZod以外のスキーマも受けたいなら、OpenAPI生成はZod専用のこれらのライブラリでは賄えない点にも注意してください。社内のAPI開発・システム連携で仕様書の整備から相談したい場合は、設計段階からお手伝いしています。
逆方向の変換:OpenAPIからのZodスキーマ・クライアント生成
既存の仕様書からZodを起こす用途で名前がよく挙がるopenapi-zod-clientは、npmの最終公開が2025年2月10日の1.18.3で、依存はzod ^3.19.1と@zodios/coreのままです。Zod 4で新しく始めるなら、同じ作者による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の使い方入門で扱っています。
@hono/zod-validatorとの違いは?
@hono/zod-validatorはリクエストをZodで検証するミドルウェアで、仕様書は出しません。@hono/zod-openapi 1.6.3は内部でこれ(^0.9.1)を使い、検証に加えてcreateRouteの定義から仕様書を出します。仕様書が要らないなら@hono/zod-validator単体で足ります。