0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-04-30

はじめに

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 をご覧ください。

  1. 複数ファイルへ分かれた OAS に対応
  2. paths, query, components の生成に対応
  3. OAS の様々なキーワードを Zod スキーマに変換
    1. minimum
    2. maximum
    3. exclusiveMinimum
    4. exclusiveMinimum
    5. などなど
  4. OAS の様々なフォーマットを Zod スキーマに変換
    1. date
    2. duration
    3. email
    4. uuid
    5. url
    6. などなど
  5. 型やバリデーションメッセージのカスタマイズ
    1. 以下、例
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 上でどう表現するか悩みましたが、愚直に descriptionzod: プレフィックス付きで 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 など、何でも構いませんのでしていただけると励みになります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?