OpenAPI から Zod スキーマを生成する otz の紹介

# はじめに

OpenAPI の仕様(以下 OAS)を基に Zod スキーマを生成する otz というツールを作りました。
npm に公開しています。

https://www.npmjs.com/package/@seiya8bit/otz

2025/04/30 現在、ベータ版としてリリースされている Zod 4 beta に対応したバージョンも公開しています。
Zod 4 beta を試す場合は、次のコマンドでインストールしてください。

```sh
npm i @seiya8bit/otz@4.0.0-next.1
```

# モチベーション

似たようなツールで、より広く利用されている素晴らしいものはいくつかありますが、以下の点で採用には至りませんでした。

- 型のカスタマイズができない
- バリデーションメッセージを含んだ Zod スキーマの生成に対応しておらず、フロントエンドのフォームバリデーションなどには直接組み込みづらい

上記の点は、Zod スキーマを自前実装し、 OAS へ変換するアプローチを採用することで自然と解決するかも知れません。

しかし、私は普段 OAS を書くために [TypeSpec](https://typespec.io/) を使用しているため、 OAS を楽に書きつつ Zod スキーマも自動生成されるようなアプローチを好みます。

OAS→Zod の変換を行うツール、 Zod→OAS の変換を行うツールを以下にまとめました。

| パッケージ名 | OAS→Zod | Zod→OAS |
| ----------- | --------| --------|
| [Zod OpenAPI Hono](https://hono.dev/examples/zod-openapi) | | ✅ |
| [zod-openapi](https://www.npmjs.com/package/zod-openapi)  | | ✅ |
| [openapi-zod-client](https://www.npmjs.com/package/openapi-zod-client/v/0.1.2) | ✅ | |

上記の他にも多くのパッケージがありますが、私の調査不足のせいか、「これだ！」と思うものがなかったため、自作することにしました。

# 主な特徴

otz が持つ特徴のうち、主なものを簡単に紹介します。
ここに記載するもの以外にも、多くのキーワードやフォーマットに対応しているため、詳しくは [README](https://www.npmjs.com/package/@seiya8bit/otz) をご覧ください。

1. 複数ファイルへ分かれた OAS に対応
1. paths, query, components の生成に対応
1. OAS の様々なキーワードを Zod スキーマに変換
    1. `minimum`
    1. `maximum`
    1. `exclusiveMinimum`
    1. `exclusiveMinimum`
    1. などなど
1. OAS の様々なフォーマットを Zod スキーマに変換
    1. `date`
    1. `duration`
    1. `email`
    1. `uuid`
    1. `url`
    1. などなど
1. 型やバリデーションメッセージのカスタマイズ
    1. 以下、例

```yaml
components:
  schemas:
    CustomObject:
      type: object
      required:
        - customValidation
      properties:
        customValidation:
          type: string
          description: 'カスタム zod: z.string().min(1, { message: "必須項目です" })'
```
↓
```ts
export const schemaCustomObject = z.object({
  // カスタム
  customValidation: z.string().min(1, { message: '必須項目です' })
})
```

最後の「型やバリデーションメッセージのカスタマイズ」がまさに欲しかった機能で、フロントエンドのフォームバリデーションで効果を発揮します。

一般的には、フロントエンドのフォームバリデーションとして Zod スキーマを使用する場合、エラーメッセージを Zod スキーマに組み込む必要があります。

例えば、 React Hook Form の場合だと次のような Zod スキーマが必要になります。

```ts
// 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 フレームワークでも使用することができるため、フロントエンドとバックエンドの両方でバリデーションを容易に実装することができます。

```yaml
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](https://www.npmjs.com/package/@hono/zod-validator) を使うことで、簡単に Zod バリデーションを追加することができて便利です。

[簡単な Petstore の例](https://github.com/seiya8bit/otz/tree/main/examples/petstore)
[Hono と組み合わせて使う例](https://github.com/seiya8bit/otz/tree/main/examples/with-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](https://v4.zod.dev/v4#true-recursive-types)

otz でもこの再帰構造の生成に対応しています。

```yaml
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'
```

```ts
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](https://v4.zod.dev/v4#number-formats)
[Top-level string formats](https://v4.zod.dev/v4#top-level-string-formats)

以前までは `z.number()` や `z.string()` に対して、 `.int()` や `.uuid()` などのフォーマットを付与することで、より厳密な型を定義することができましたが、それらの型がトップレベルで定義できるようになりました。

全ては書きませんが、次のような型を直接定義することができるようになりました。

```ts
// 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 スキーマの生成結果を載せておきます(長いので折りたたんでいます)。

<details><summary>openapi.yaml</summary>

```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
```
</details>

<details><summary>Zod 生成結果</summary>

```ts
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()
});
```
</details>

# 最後に

当初は自分の悩みを解決するために始めたツール開発ですが、開発するにあたって [OpenAPI の仕様](https://swagger.io/specification/)を全て読んだり、なかなか大がかりな開発になってしまいました(暇な時間でちまちま作っていたので、なんやかんやで 3~4 ヵ月ほどかかりました)。

ある程度、実用に耐えるレベルになったと思うので、この記事を書きました。

もし使っていただける方がいましたら、感想や Issue/PR など、何でも構いませんのでしていただけると励みになります。