「JSONサンプルから作る」では足りない場面がある

すでにこのサイトには JSON to Zod変換 があります。APIレスポンスなどのJSONを貼り付けると、そこからZodスキーマを推測して生成するツールです。

ただ、これには原理的な限界があります。値のサンプルからは「そのフィールドが必須かどうか」が分からないからです。{"nickname": "alice"} というサンプルを見ても、nicknameが必ず存在するのか、たまたま今回入っていただけなのかは判断できません。同じ理由で、"[email protected]" という値からメールアドレス形式であるべきかも決まりません。

一方、JSON Schemaにはその情報が最初から書いてあります。

{
  "type": "object",
  "properties": {
    "id": { "type": "integer" },
    "email": { "type": "string", "format": "email" },
    "nickname": { "type": "string" }
  },
  "required": ["id", "email"]
}

JSON SchemaとZodを相互変換する仕組み

JSON Schema ⇄ Zod 変換 は、この情報を落とさずにZodへ持っていくツールです。逆に、コード側で書いたZodスキーマからJSON Schemaを書き出すこともできます。OpenAPIの components.schemas はJSON Schemaなので、そのまま貼り付けて使えます。

この記事では、変換で実際に何が起きているかを実装ベースで説明します。

最大の落とし穴:requiredと.optional()は既定値が逆

2つの仕様で、「何も書かなかったときの意味」が正反対です。

必須の表し方何も書かないと
JSON Schemarequired 配列に名前を列挙する任意(optional)
Zod.optional() を付けない必須

つまりJSON Schemaは「明示的に必須と言わない限り任意」、Zodは「明示的に任意と言わない限り必須」です。ここを取り違えると、必須のはずのフィールドが全部optionalになった型が出来上がります。

変換ではこの差を吸収しています。JSON Schema→Zodの向きでは、required に載っていないプロパティにだけ .optional() を付けます。

const required: string[] = Array.isArray(schema.required) ? schema.required : [];

const lines = Object.entries(properties).map(([key, value]) => {
    let field = schemaToZod(value, depth + 1, warnings);
    if (!required.includes(key)) field += '.optional()';
    return `${indent}${formatKey(key)}: ${field},`;
});

逆方向では、.optional() が付いていないプロパティを集めて required 配列を作ります。上の例を変換すると、こうなります。

import { z } from 'zod';

export const schema = z.object({
    id: z.number().int(),
    email: z.string().email(),
    nickname: z.string().optional(),
});

export type Schema = z.infer<typeof schema>;

z.infer の型エイリアスも一緒に出力しているので、バリデーションと型定義が同じ場所から生まれる状態になります。

制約の対応表

型だけでなく、細かい制約も両方向で対応付けています。

JSON SchemaZod
"type": "integer"z.number().int()
"format": "email"z.string().email()
"format": "date-time"z.string().datetime()
"minLength": 3z.string().min(3)
"minimum": 0z.number().min(0)
"enum": ["a","b"](全て文字列)z.enum(["a", "b"])
"enum"(文字列以外を含む)z.union([z.literal(...), ...])
"type": ["string","null"]z.string().nullable()
"additionalProperties": false.strict()
oneOf / anyOfz.union([...])

enum の扱いだけ少し分岐があります。Zodの z.enum()文字列専用なので、数値やnullが混ざった enum はそのまま渡せません。その場合は z.literal() のunionに落とします。

if (Array.isArray(schema.enum)) {
    const allStrings = schema.enum.every((v) => typeof v === 'string');
    if (allStrings) return `z.enum([${schema.enum.map((v) => JSON.stringify(v)).join(', ')}])`;
    return `z.union([${schema.enum.map((v) => `z.literal(${JSON.stringify(v)})`).join(', ')}])`;
}

Zod→JSON Schemaはコードを実行しない

逆方向には、実装上の制約があります。zod-to-json-schema のような既存ライブラリは実行時のZodオブジェクトを受け取って変換します。しかしこのサイトのツールはすべてブラウザ内で完結する方針なので、貼り付けられたコードを eval するわけにはいきません

そこで、Zodのコードをテキストとして解析しています。z.string().min(1).optional() のような式を、呼び出し名(string)・引数・メソッドチェーン(min(1), optional())に分解します。

ここで面倒なのが括弧と文字列リテラルです。単純に ) を探すと入れ子で破綻しますし、z.literal('a,b') のようにカンマを含む文字列があるとフィールドの分割にも失敗します。そのため、対応する括弧を探す処理ではクォート内かどうかを追跡しています。

const findClosing = (source: string, openIndex: number): number => {
    let depth = 0;
    let quote: string | null = null;
    for (let i = openIndex; i < source.length; i += 1) {
        const char = source[i];
        if (quote) {
            if (char === '\\') i += 1;        // エスケープを飛ばす
            else if (char === quote) quote = null;
            continue;
        }
        if (char === '"' || char === "'" || char === '`') { quote = char; continue; }
        if (char === '(' || char === '[' || char === '{') depth += 1;
        else if (char === ')' || char === ']' || char === '}') {
            depth -= 1;
            if (depth === 0) return i;
        }
    }
    return -1;
};

同じ考え方で、オブジェクトのフィールドを分割するときもトップレベルのカンマだけを区切りとして扱います。おかげで z.object({ label: z.literal('a,b') }) のような入力も壊れません。

import { z } from 'zod';export const user = が付いたままコピーしても動くように、z. で始まる位置から解析を開始します。エディタからそのまま貼れることを優先しました。

変換できないものは黙って捨てない

JSON Schemaには、Zodに素直に落ちない書き方があります。

  • $ref: 参照先を解決しないため z.any() になります
  • allOf: 交差型として表現できないため、先頭のスキーマのみ変換します
  • if / then / not: 非対応です

これらを黙って z.any() にすると、一見きれいに変換できたように見えて検証が素通しになるという最悪の結果になります。そのため、変換結果の下に警告として表示するようにしました。

• $ref (#/$defs/User) は展開されないため z.any() になりました

.refine() のような任意の関数も同じです。JSON Schemaには表現手段が無いため、逆方向の変換では落ちます。生成物はあくまで移行のたたき台として、必ず内容を確認してください。

使いどころ

OpenAPI定義が先にあるプロジェクトなら、components.schemas の中身を貼り付けるだけでフロントの型と検証が同時に手に入ります。逆にコード側でZodを先に書いているなら、逆方向の変換でAPIドキュメント用のJSON Schemaを起こせます。

JSONのサンプルしか手元に無い場合は、これまで通り JSON to Zod変換 が向いています。OpenAPIのスキーマそのものを作りたいときは JSON to OpenAPI変換 もあります。いずれのツールもブラウザ内で処理が完結し、貼り付けたスキーマが外部へ送信されることはありません。

よくある質問

JSON to Zodとどちらを使えばいいですか?

手元にJSON Schema(またはOpenAPI定義)があるならこちらです。必須・enum・文字列のformatといった制約が保持されるため、生成されるZodスキーマが正確になります。持っているのがAPIレスポンスなどの値のサンプルだけなら、JSON to Zod変換 を使ってください。

$refを含むスキーマはどうすればいいですか?

参照先は展開されないため、その位置は z.any() になり警告が出ます。共通定義を $defs に切り出している場合は、貼り付ける前に参照先を展開しておくか、生成後にその部分だけ手で書き換えてください。

生成したZodスキーマをそのまま本番で使えますか?

骨組みとしては使えますが、元のJSON Schemaに書かれていない業務ルール(許可するメールドメイン、フィールド間の整合性など)は生成されません。また patternz.string().regex() に移されるため、元の正規表現がECMAScript互換でない場合は挙動が変わることがあります。必ず内容を確認してから使ってください。

貼り付けたスキーマは送信されますか?

いいえ。JSON Schema ⇄ Zod 変換 はブラウザ内で処理が完結します。Zodのコードも実行せずテキストとして解析しているため、社内APIの定義をそのまま貼り付けても外部に出ることはありません。