はじめに
OpenAPI の仕様(以下 OAS)を基に Zod スキーマを生成する otz というツールを作りました。
npm に公開しています。
2025/04/30 現在、ベータ版としてリリースされている Zod 4 beta に対応したバージョンも公開しています。
Zod 4 beta を試す場合は、次のコマンドでインストールしてください。
npm i @seiya8bit/otz@4.0.0-next.1
モチベーション
似たようなツールで、より広く利用されている素晴らしいものはいくつかありますが、以下の点で採用には至りませんでした。
- 型のカスタマイズができない
- バリデーションメッセージを含んだ Zod スキーマの生成に対応しておらず、フロントエンドのフォームバリデーションなどには直接組み込みづらい
上記の点は、Zod スキーマを自前実装し、 OAS へ変換するアプローチを採用することで自然と解決するかも知れません。
しかし、私は普段 OAS を書くために TypeSpec を使用しているため、 OAS を楽に書きつつ Zod スキーマも自動生成されるようなアプローチを好みます。
OAS→Zod の変換を行うツール、 Zod→OAS の変換を行うツールを以下にまとめました。
パッケージ名 | OAS→Zod | Zod→OAS |
---|---|---|
Zod OpenAPI Hono | ✅ | |
zod-openapi | ✅ | |
openapi-zod-client | ✅ |
上記の他にも多くのパッケージがありますが、私の調査不足のせいか、「これだ!」と思うものがなかったため、自作することにしました。
主な特徴
otz が持つ特徴のうち、主なものを簡単に紹介します。
ここに記載するもの以外にも、多くのキーワードやフォーマットに対応しているため、詳しくは README をご覧ください。
- 複数ファイルへ分かれた OAS に対応
- paths, query, components の生成に対応
- OAS の様々なキーワードを Zod スキーマに変換
minimum
maximum
exclusiveMinimum
exclusiveMinimum
- などなど
- OAS の様々なフォーマットを Zod スキーマに変換
date
duration
email
uuid
url
- などなど
- 型やバリデーションメッセージのカスタマイズ
- 以下、例
components:
schemas:
CustomObject:
type: object
required:
- customValidation
properties:
customValidation:
type: string
description: 'カスタム zod: z.string().min(1, { message: "必須項目です" })'
↓
export const schemaCustomObject = z.object({
// カスタム
customValidation: z.string().min(1, { message: '必須項目です' })
})
最後の「型やバリデーションメッセージのカスタマイズ」がまさに欲しかった機能で、フロントエンドのフォームバリデーションで効果を発揮します。
一般的には、フロントエンドのフォームバリデーションとして Zod スキーマを使用する場合、エラーメッセージを Zod スキーマに組み込む必要があります。
例えば、 React Hook Form の場合だと次のような Zod スキーマが必要になります。
// https://www.npmjs.com/package/@hookform/resolvers より拝借
const schema = z.object({
name: z.string().min(1, { message: 'Required' }),
age: z.number().min(10),
});
otz を使うと、次の OAS を用意するだけで上記の Zod スキーマを生成することができるようになります。
当然、この Zod スキーマは Hono などの Web フレームワークでも使用することができるため、フロントエンドとバックエンドの両方でバリデーションを容易に実装することができます。
components:
schemas:
User:
type: object
required:
- name
- age
properties:
name:
type: string
description: "zod: z.string().min(1, { message: 'Required' })"
age:
type: number
minimum: 10
この機能を OAS 上でどう表現するか悩みましたが、愚直に description
へ zod:
プレフィックス付きで Zod スキーマを書く方法に落ち着きました。
実践例
otz の実践例も公開しているので紹介します。
どちらも OAS の生成に TypeSpec を使っていますが、もちろん自前で書いた OAS を使っていただいて問題ありません。
また、Hono を使う場合は、以下の例にあるように、 @hono/zod-validator を使うことで、簡単に Zod バリデーションを追加することができて便利です。
簡単な Petstore の例
Hono と組み合わせて使う例
注意点
otz はあくまで個人の悩みを解決するために作られたツールのため、充分なテストができていなかったり、バグがないとも言い切れません。
また、ドキュメントの整備も追いついていなかったりするので、 時間を見つけて更新作業を行う予定です。
バグを発見した場合は Issue や PR を出していただけると大変助かります。
Zod 4 beta 対応について
otz@4.0.0-next.1
では、 Zod v3 系では実現できなかったいくつかの機能を実装しています。
1. 再帰構造への対応
Zod 4 beta から、再帰構造を表現できる z.interface()
という API が追加されました。
True recursive types
otz でもこの再帰構造の生成に対応しています。
components:
schemas:
Category:
type: object
required:
- name
- subCategories
properties:
name:
type: string
subCategory:
$ref: '#/components/schemas/Category'
subCategories:
type: array
items:
$ref: '#/components/schemas/Category'
export const schemaCategory = z.interface({
name: z.string(),
get subCategory() {
return schemaCategory;
},
get subCategories() {
return z.array(schemaCategory);
}
});
2. Number formats / String formats のサポート
Number formats
Top-level string formats
以前までは z.number()
や z.string()
に対して、 .int()
や .uuid()
などのフォーマットを付与することで、より厳密な型を定義することができましたが、それらの型がトップレベルで定義できるようになりました。
全ては書きませんが、次のような型を直接定義することができるようになりました。
// Number formats
z.int();
z.float32();
z.float64();
// String formats
z.email();
z.uuid();
z.cuid2();
z.iso.date();
z.iso.datetime();
z.iso.duration();
z.iso.time();
otz でもこれらの生成をサポートしています。
以下、全てではありませんが、 OAS と Zod スキーマの生成結果を載せておきます(長いので折りたたんでいます)。
openapi.yaml
openapi: 3.0.0
info:
title: (title)
version: 0.0.0
tags: []
paths: {}
components:
schemas:
NumberFormats:
type: object
required:
- int
- int32
- int64
- float32
- float64
- uint16
- uint32
- uint64
properties:
int:
type: integer
int32:
type: integer
format: int32
int64:
type: integer
format: int64
float32:
type: number
format: float
float64:
type: number
format: double
uint16:
type: integer
format: uint16
uint32:
type: integer
format: uint32
uint64:
type: integer
format: uint64
StringFormat:
type: object
required:
- email
- uuid
- uuidv4
- uuidv6
- uuidv7
- uri
- url
- emoji
- cuid
- cuid2
- ulid
- ipv4
- ipv6
- date
- time
- dateTime
- duration
properties:
email:
type: string
format: email
uuid:
type: string
format: uuid
uuidv4:
type: string
format: uuidv4
uuidv6:
type: string
format: uuidv6
uuidv7:
type: string
format: uuidv7
uri:
type: string
format: uri
url:
type: string
format: url
emoji:
type: string
format: emoji
cuid:
type: string
format: cuid
cuid2:
type: string
format: cuid2
ulid:
type: string
format: ulid
ipv4:
type: string
format: ipv4
ipv6:
type: string
format: ipv6
date:
type: string
format: date
time:
type: string
format: time
dateTime:
type: string
format: date-time
duration:
type: string
format: duration
Zod 生成結果
import { z } from "zod";
export const schemaNumberFormats = z.interface({
int: z.number(),
int32: z.int32(),
int64: z.number(),
float32: z.float32(),
float64: z.float64(),
uint16: z.int(),
uint32: z.uint32(),
uint64: z.uint64()
});
export const schemaStringFormat = z.interface({
email: z.email(),
uuid: z.uuid(),
uuidv4: z.uuidv4(),
uuidv6: z.uuidv6(),
uuidv7: z.uuidv7(),
uri: z.url(),
url: z.url(),
emoji: z.emoji(),
cuid: z.cuid(),
cuid2: z.cuid2(),
ulid: z.ulid(),
ipv4: z.ipv4(),
ipv6: z.ipv6(),
date: z.iso.date(),
time: z.iso.time(),
dateTime: z.iso.datetime(),
duration: z.iso.duration()
});
最後に
当初は自分の悩みを解決するために始めたツール開発ですが、開発するにあたって OpenAPI の仕様を全て読んだり、なかなか大がかりな開発になってしまいました(暇な時間でちまちま作っていたので、なんやかんやで 3~4 ヵ月ほどかかりました)。
ある程度、実用に耐えるレベルになったと思うので、この記事を書きました。
もし使っていただける方がいましたら、感想や Issue/PR など、何でも構いませんのでしていただけると励みになります。