この記事は Zod 公式ドキュメント(Basics、Defining schemas / API、Migration / v4、Error customization、Error formatting、JSON Schema、Metadata、Zod Mini)の最新版を一次情報として、実務で「どの場面で・どう書くか」を判断できるところまで踏み込みます。
1. なぜ Zod なのか(型と検証の「単一の正」)
TypeScript の型はコンパイル時に消えます。tsc を通れば型エラーは消えますが、実行時にやってくる JSON・フォーム入力・環境変数は型システムの外側から来るデータで、any あるいは unknown でしかありません。ここで多くのバグが生まれます。
// よくある事故:型を「主張」しているだけで、実際には検証していない
const res = await fetch("/api/user");
const user = (await res.json()) as User; // ← as は嘘をつける。実行時は何の保証もない
user.email.toLowerCase(); // email が undefined なら本番でクラッシュ
as は「コンパイラを黙らせる」だけで、1バイトの検証も行いません。Zod はこの境界に実行時の検査を差し込み、同時にその検査から静的な型を導出します。
import * as z from "zod";
const User = z.object({
id: z.uuid(),
email: z.email(),
name: z.string().min(1),
});
type User = z.infer<typeof User>; // ← スキーマから型を生成(手書きの type 定義は不要)
const res = await fetch("/api/user");
const user = User.parse(await res.json()); // 実行時に検証し、user は User 型として安全
ポイントは "Parse, don't validate" という設計思想です。Zod は「正しいかどうかを boolean で返す」のではなく、「信頼できない unknown を、信頼できる型の値へ変換する」関数として働きます。検証を通過した先のコードは、型を前提に安心して書けます。これが Zod を入れる最大の理由です。
適用範囲の原則:Zod は信頼境界にだけ置く。 API・フォーム・環境変数・外部 SDK の戻り値など「外から来る
unknown」を一度parseしたら、それ以降のアプリ内部は TypeScript の型に任せます。すでに型がついている値を関数のたびに再検証するのは、コストの無駄であり SRP 違反です。
2. 基礎:parse / safeParse と型推論
2-1. スキーマ定義とパース
import * as z from "zod";
const Player = z.object({
username: z.string(),
xp: z.number(),
});
// .parse(): 検証に成功すると「入力のディープクローン」を型付きで返す。失敗時は throw
Player.parse({ username: "billie", xp: 100 }); // => { username: "billie", xp: 100 }
// 非同期の検証(refine が Promise を返す等)を含む場合は parseAsync
await Player.parseAsync({ username: "billie", xp: 100 });
2-2. 例外を投げない safeParse(実務の既定)
API ハンドラやフォームでは、例外を投げる .parse() より、結果オブジェクトを返す .safeParse() が扱いやすく、制御フローが素直になります。返り値は判別可能なユニオンです。
const result = Player.safeParse({ username: 42, xp: "100" });
if (!result.success) {
result.error; // ZodError インスタンス(result.error.issues に詳細)
} else {
result.data; // { username: string; xp: number } として型がつく
}
// 非同期版
await Player.safeParseAsync(input);
.parse() で try/catch する場合は、error instanceof z.ZodError で握り、error.issues(検証問題の配列)を参照します。
2-3. 型推論:z.infer / z.input / z.output
スキーマから型を取り出すのが Zod の真骨頂です。変換(transform)やデフォルト(default)があると入力型と出力型がズレるため、3つを使い分けます。
const FormSchema = z.object({
// 入力は文字列、出力は数値(変換あり)
age: z.string().transform((s) => Number.parseInt(s, 10)),
});
type In = z.input<typeof FormSchema>; // { age: string } ← パース前(フォームの生値)
type Out = z.output<typeof FormSchema>; // { age: number } ← パース後(業務ロジックが受け取る値)
// z.infer は z.output のエイリアス(= 検証後の最終型)
type Final = z.infer<typeof FormSchema>; // { age: number }
| ヘルパー | 何の型か | 主な用途 |
|---|---|---|
z.input | パース前の入力の型 | フォームの生値・API の生ペイロード |
z.output | パース後の出力の型 | 業務ロジックが受け取る確定値 |
z.infer | z.output と同じ | 変換がない通常スキーマの型取得 |
3. 【Zod 4 の最新仕様】v3 からの差分マップ
既存記事の多くは Zod 3 のままです。ここを押さえると一気に最新になります。
| 項目 | Zod 3(旧) | Zod 4(最新) |
|---|---|---|
| インポート | import { z } from "zod" | import * as z from "zod"({ z } も可) |
| 文字列フォーマット | z.string().email() | z.email()(旧形は非推奨) |
| ISO 日付/時刻 | z.string().datetime() | z.iso.datetime() / z.iso.date() |
| エラーメッセージ指定 | message / invalid_type_error / required_error | error(文字列 or 関数)に統一 |
| エラー整形 | error.format() / error.flatten() | z.treeifyError / z.flattenError / z.prettifyError |
| JSON Schema 変換 | 外部ライブラリが必要 | z.toJSONSchema() を標準搭載 |
| メタデータ | .describe() のみ | レジストリ + .meta({ id, title, ... }) |
| バンドル最適化 | — | zod/mini(関数型・ツリーシェイク対応) |
z.literal | 単一値のみ | 複数値可 z.literal([200, 201, 204]) |
| 環境変数 boolean | 自前実装 | z.stringbool()("true"/"1"/"yes" 等) |
移行は段階的でよい。 旧 API(
z.string().email()、message)は非推奨ながらまだ動くので、ビルドが壊れることはありません。新規コードから新形式に寄せ、触れたファイルを少しずつ直す(ボーイスカウト・ルール)のが安全です。
4. プリミティブ・文字列フォーマット・型強制(coerce)
4-1. トップレベル文字列フォーマット
Zod 4 では実務で多用するフォーマットがトップレベル関数になりました。z.string().email() のメソッド形より短く、ツリーシェイクにも有利です。
z.email(); // メール
z.uuid(); // UUID(z.uuidv4() / z.uuidv7() も)
z.url(); // URL(z.httpUrl() は http/https 限定)
z.e164(); // 電話番号(E.164)
z.jwt(); // JWT(z.jwt({ alg: "HS256" }) でアルゴリズム指定)
z.base64(); // Base64
z.ipv4(); // IPv4(z.cidrv4() で CIDR ブロック)
z.nanoid(); // nanoid / z.cuid2() / z.ulid()
// ISO 系は z.iso 名前空間に集約
z.iso.date(); // "YYYY-MM-DD"
z.iso.datetime(); // ISO 8601 日時
z.iso.time(); // "HH:MM:SS"
// メール正規表現は用途に応じて差し替え可能(厳密さ vs 寛容さのトレードオフ)
z.email({ pattern: z.regexes.html5Email }); // ブラウザ標準準拠
z.email({ pattern: z.regexes.rfc5322Email }); // RFC 5322 準拠
文字列の基本検証と変換も健在です。min / max / regex / startsWith などに加え、trim() / toLowerCase() などの正規化を検証パイプラインに組み込めるのが地味に強力です(手作業の前処理を1か所に集約できる)。
// 例:入力を正規化してから検証(前後空白除去 → 小文字化 → メール形式)
const Email = z.string().trim().toLowerCase().pipe(z.email());
Email.parse(" Foo@Example.com "); // => "foo@example.com"
4-2. 型強制(coerce)と数値フォーマット
「文字列で来るが数値として扱いたい」——クエリパラメータ・環境変数・フォームの典型例には z.coerce が効きます。内部で Number(input) 等を通してから検証します。
z.coerce.number(); // Number(input) してから number 検証
z.coerce.boolean(); // Boolean(input)(注意:空文字以外は基本 true)
z.coerce.date(); // new Date(input)
// 数値の精密フォーマット(v4 で拡充)
z.int(); // 安全整数のみ
z.int32(); // 32bit 整数の範囲
z.number().positive().multipleOf(0.01); // 正の数・小数第2位まで(金額など)
z.coerce.boolean()の落とし穴:"false"は非空文字列なのでtrueになります。環境変数の"true"/"false"を正しく扱うには次章のz.stringbool()を使ってください。
5. オブジェクト設計:strict / loose / 合成 / 再帰 / 判別ユニオン
5-1. 未知キーの扱い(セキュリティに直結)
z.object は**既定で未知のキーを黙って除去(strip)**します。用途に応じて3モードを使い分けます。
z.object({ name: z.string() }); // strip(既定):未知キーは出力から落とす
z.strictObject({ name: z.string() }); // strict:未知キーがあればエラー
z.looseObject({ name: z.string() }); // loose:未知キーをそのまま通す
外部からの入力では **strictObject で「想定外のフィールドを弾く」**のが防御的です(マスアサインメント対策)。一方、将来の後方互換を残したい公開 API のレスポンス検証では既定の strip が無難です。
5-2. スキーマの合成(DRY の要)
1つの基底スキーマから派生させることで、型定義の重複をなくします。
const User = z.object({
id: z.uuid(),
email: z.email(),
name: z.string(),
passwordHash: z.string(),
});
// 作成入力:id は不要、password は生文字列
const CreateUser = User.omit({ id: true, passwordHash: true }).extend({
password: z.string().min(12),
});
// 更新入力:全フィールド任意
const UpdateUser = CreateUser.partial();
// 公開用:機密フィールドを除外
const PublicUser = User.omit({ passwordHash: true });
// 他にも:.pick() / .required() / .keyof() / .extend() / .catchall()
これらはスキーマの代数演算です。User を一度直せば派生先すべてに型と検証が伝播するため、ETC(Easy To Change)を満たします。
5-3. 再帰スキーマ(v4 でキャスト不要に)
Zod 4 では getter を使って、型キャストなしで再帰・相互再帰を表現できます。
const Category = z.object({
name: z.string(),
get subcategories() {
return z.array(Category); // 自己参照
},
});
// 相互再帰も自然に書ける
const User = z.object({
email: z.email(),
get posts() {
return z.array(Post);
},
});
const Post = z.object({
title: z.string(),
get author() {
return User;
},
});
5-4. 判別ユニオン(API 結果型の決定版)
「成功なら data、失敗なら error」のようなタグ付きユニオンは z.discriminatedUnion が最適です。判別キーで分岐するので、通常の z.union よりエラーメッセージが的確で高速です。
const ApiResult = z.discriminatedUnion("status", [
z.object({ status: z.literal("success"), data: z.string() }),
z.object({ status: z.literal("error"), code: z.number(), message: z.string() }),
]);
const parsed = ApiResult.parse(payload);
if (parsed.status === "success") {
parsed.data; // ← 型が success 側に絞り込まれる
} else {
parsed.code; // ← error 側に絞り込まれる
}
Zod 4 では判別ユニオンの中にユニオンやパイプ、別の判別ユニオンをネストできるようになり、表現力が大きく上がりました。
6. カスタム検証と変換:refine / superRefine / transform / pipe
6-1. refine:単一の論理チェック
組み込みバリデータで表せないビジネスルールは refine で足します。path を指定すると、エラーを特定フィールドに紐づけられる(フォーム表示で重要)。
const SignUp = z
.object({
password: z.string().min(12),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
error: "パスワードが一致しません",
path: ["confirm"], // confirm フィールドのエラーとして扱う
});
6-2. superRefine / check:複数の問題を一度に追加
1回の検証で複数のエラーを出したい・コンテキストに応じて出し分けたい場合は superRefine(低レベル API。Zod 4 では .check も同系統)を使います。
const Password = z.string().superRefine((val, ctx) => {
if (val.length < 12) {
ctx.addIssue({ code: "custom", message: "12文字以上にしてください" });
}
if (!/[A-Z]/.test(val)) {
ctx.addIssue({ code: "custom", message: "大文字を1文字以上含めてください" });
}
if (!/[0-9]/.test(val)) {
ctx.addIssue({ code: "custom", message: "数字を1文字以上含めてください" });
}
});
6-3. transform / pipe / preprocess:値の作り替え
検証と同時に値を別の形へ変換できます。transform は出力型を変え、pipe は「あるスキーマの出力を次のスキーマの入力につなぐ」合成です。
// transform:検証後に変換(入力 string → 出力 number)
const StrToLen = z.string().transform((s) => s.length);
// pipe:正規化 → 再検証の連結
const Slug = z
.string()
.trim()
.toLowerCase()
.pipe(z.string().regex(/^[a-z0-9-]+$/, { error: "英小文字・数字・ハイフンのみ" }));
// preprocess:検証「前」に前処理(型が不定な生入力の整形)
const Quantity = z.preprocess((v) => (v === "" ? undefined : Number(v)), z.number().int().positive());
使い分け: 値を変えないチェックは
refine、検証ついでに正規化したいならtransform/pipe、検証の前段で雑な入力を整えるならpreprocess。型を変えずに値だけ整えたい(かつ JSON Schema 生成も保ちたい)場合は v4 新設の.overwrite()が使えます。
7. エラー設計:メッセージ・整形・ローカライズ(日本語化)
ここは**ユーザー体験(UX)とアクセシビリティ(a11y)**に直結します。検証は「弾く」だけでなく「何をどう直せばよいかを伝える」ところまでが仕事です。
7-1. error パラメータへの統一
Zod 4 では、かつて3つに分かれていたエラー指定(message / invalid_type_error / required_error)が error 一本に統一されました。文字列でも関数でも渡せます。
// 文字列形:シンプルに固定メッセージ
z.string().min(5, { error: "5文字以上で入力してください" });
z.string({ error: "文字列を入力してください" });
// 関数形:状況(未入力 or 型不一致)で出し分け
z.string({
error: (iss) => (iss.input === undefined ? "必須項目です" : "文字列で入力してください"),
});
// 検証コンテキストの活用(最小値などを動的に埋め込む)
z.string().min(8, {
error: (iss) => `パスワードは${iss.minimum}文字以上必要です`,
});
7-2. エラーの整形:用途別に3つ
ZodError をそのまま画面に出すのは禁物です。用途に応じて整形します。
| 関数 | 出力 | 使う場面 |
|---|---|---|
z.flattenError | { formErrors, fieldErrors }(1階層) | フォーム / API レスポンス(最頻出) |
z.treeifyError | スキーマ構造を反映したネスト木 { errors, properties } | 深くネストした複雑なスキーマ |
z.prettifyError | 記号付きの人間可読な文字列 | CLI・ログ・デバッグ |
const result = SignUp.safeParse(input);
if (!result.success) {
// フォーム用:fieldErrors.confirm?.[0] のように引ける
const flat = z.flattenError(result.error);
// => { formErrors: [...], fieldErrors: { confirm: ["パスワードが一致しません"] } }
// ログ用:そのまま console に出せる読みやすい文字列
console.error(z.prettifyError(result.error));
}
7-3. 日本語ロケールとグローバル設定
デフォルトの英語メッセージをアプリ全体で日本語化できます。組み込みロケールに ja が含まれます。
// app の最上位(例:layout やエントリ)で一度だけ設定
import * as z from "zod";
import { ja } from "zod/locales";
z.config(ja()); // 既定メッセージが日本語になる
// さらに横断ルールを上書きしたい場合
z.config({
customError: (iss) => {
if (iss.code === "invalid_type") return `型が不正です(期待: ${iss.expected})`;
},
});
エラーの優先順位は「①スキーマ定義のメッセージ → ②parse 時に渡したメッセージ → ③z.config のグローバル → ④ロケール既定」です。個別 > 全体の順で効くと覚えてください。
8. 応用①:React Hook Form 連携(zodResolver)
フォームこそ Zod の主戦場です。@hookform/resolvers(v5 は Zod 4 対応)の zodResolver を噛ませるだけで、型・検証・エラーメッセージが一気通貫になります。
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
const ContactSchema = z.object({
name: z.string().min(2, { error: "お名前は2文字以上で入力してください" }),
email: z.email({ error: "正しいメールアドレスを入力してください" }),
message: z.string().min(20, { error: "20文字以上で具体的に入力してください" }),
});
type ContactInput = z.infer<typeof ContactSchema>;
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ContactInput>({
resolver: zodResolver(ContactSchema),
mode: "onBlur", // フォーカスを外したタイミングで検証(過剰な再検証を避ける)
});
return (
<form onSubmit={handleSubmit(async (data) => { /* data は ContactInput 型で安全 */ })} noValidate>
<label htmlFor="name">お名前</label>
<input
id="name"
{...register("name")}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "name-error" : undefined}
/>
{/* a11y:エラーは aria-describedby で関連付け、role/aria-live で読み上げ */}
{errors.name && (
<p id="name-error" role="alert" aria-live="polite">
{errors.name.message}
</p>
)}
{/* email / message も同様 */}
<button type="submit" disabled={isSubmitting}>送信</button>
</form>
);
}
押さえどころ:
noValidateをフォームに付け、ブラウザ標準の検証 UI を切って Zod に一本化します(メッセージの一貫性)。- a11y の肝は3点セット: エラー入力に
aria-invalid、エラー文にidを振ってaria-describedbyで関連付け、role="alert"+aria-live="polite"でスクリーンリーダーに通知。 - 同じ
ContactSchemaをサーバー側でもsafeParseに使えば、クライアントを信用せずに二重で守れます(次章)。スキーマは1つ、適用は両端——これが DRY と安全の両立です。
9. 応用②:環境変数の起動時 fail-fast
「process.env.X が undefined で本番が落ちる」事故は、起動時に一度だけ検証して撥ねることで根絶できます。z.coerce と v4 新設の z.stringbool() が効きます。
// src/env.ts —— アプリ起動時に一度だけ評価される
import * as z from "zod";
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.url(), // 形式まで検証
PORT: z.coerce.number().int().positive().default(3000), // "3000"(文字列)→ 3000
ENABLE_CACHE: z.stringbool().default(false), // "true"/"1"/"yes" → true、"false"/"0" → false
SENTRY_DSN: z.url().optional(),
});
// 失敗したら「何の変数が・なぜ」ダメかを表示して即終了(fail-fast)
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
console.error("❌ 環境変数が不正です:\n", z.prettifyError(parsed.error));
throw new Error("Invalid environment variables");
}
export const env = parsed.data; // 以降は env.PORT が number として型安全に使える
これで「秘密情報の欠落・URL の打ち間違い・boolean の解釈ミス」がデプロイ直後に確定的に見つかります。.env の値そのものはログに出さないこと(メッセージは変数名と理由に留める)。
10. 応用③:API 境界(Next.js Route Handler / Server Action)
サーバーはクライアントを一切信用しないのが原則です。Route Handler では受け取った unknown を必ず safeParse し、不正なら 400 を返します。
// app/api/contact/route.ts
import { NextResponse } from "next/server";
import * as z from "zod";
const ContactSchema = z.strictObject({
// strictObject で想定外フィールドを拒否(防御的)
name: z.string().min(2).max(50),
email: z.email(),
message: z.string().min(20).max(2000),
});
export async function POST(req: Request) {
const json = await req.json().catch(() => null); // 壊れた JSON も握る
const parsed = ContactSchema.safeParse(json);
if (!parsed.success) {
// クライアントには整形済みのフィールドエラーだけ返す(内部情報を漏らさない)
return NextResponse.json(
{ error: z.flattenError(parsed.error).fieldErrors },
{ status: 400 },
);
}
// parsed.data はここで完全に型安全。以降の処理は型を信頼してよい
await sendEmail(parsed.data);
return NextResponse.json({ ok: true });
}
Server Action でも同型のパターンが使えます。**「境界で1回 parse、内部は型に委ねる」**を徹底すると、コードベース全体の防御線が route.ts / action に集約され、レビューしやすくなります(SRP)。
11. 応用④:z.toJSONSchema() で OpenAPI・LLM 構造化出力へ
Zod 4 は JSON Schema を標準で生成できます。これにより、1つの Zod スキーマを「実行時検証」「TypeScript 型」「JSON Schema」の3用途で使い回せます。OpenAPI ドキュメント生成や、LLM の構造化出力(structured output / function calling)のスキーマとして極めて有用です。
import * as z from "zod";
const Article = z.object({
title: z.string().meta({ description: "記事のタイトル", examples: ["Zod 4 入門"] }),
tags: z.array(z.string()).meta({ description: "関連タグ" }),
publishedAt: z.iso.datetime(),
});
// メタデータ(.meta)も JSON Schema に反映される
const jsonSchema = z.toJSONSchema(Article, { target: "draft-2020-12" });
// 入力 vs 出力の型差も制御できる(transform を含むスキーマで有用)
z.toJSONSchema(Article, { io: "input" }); // パース前の形
主なオプション:target(draft-2020-12〔既定〕/ draft-07 / openapi-3.0)、io("input" / "output")、unrepresentable(bigint など表現不能型の扱い)、reused: "ref"(重複スキーマを $defs に抽出)。
生成AI 連携での要点: LLM に「この JSON Schema に従って出力せよ」と渡し、返ってきた JSON を同じ Zod スキーマで
parseすれば、モデル出力の妥当性を実行時に保証できます。スキーマ定義・プロンプト制約・出力検証が単一のArticleに集約され、ズレようがありません。当方が手がける生成AI プロダクトでも、この「スキーマ駆動」を信頼性の土台にしています。
12. パフォーマンスと Zod Mini(バンドル最適化の判断)
Zod 4 はコア実装が刷新され、v3 比で文字列約14倍・配列約7倍・オブジェクト約6.5倍高速です。多くのアプリでは無印の zod のままで十分です。
フロントエンドのバンドルサイズが本当にクリティカル(低速回線が主要顧客、など)な場合のみ、関数型・ツリーシェイク前提の zod/mini を検討します。
// 無印(メソッドチェーン・DX 重視)
import * as z from "zod";
const a = z.string().min(5).max(10).optional();
// Mini(関数合成・サイズ重視)。.check() に制約を渡す
import * as zm from "zod/mini";
const b = zm.optional(zm.string().check(zm.minLength(5), zm.maxLength(10)));
| Zod(無印) | Zod Mini | |
|---|---|---|
| API | メソッドチェーン | 関数合成 + .check() |
| バンドル(オブジェクト例) | 約 13.1kb | 約 4.0kb |
| DX / 自動補完 | ◎ | △(やや冗長) |
| 推奨 | バックエンド・大半の Web | サイズ厳格なフロント |
判断基準: バックエンドではバンドルサイズの影響は無視できるため、迷わず無印。フロントでもまず無印で計測し、バンドル分析で Zod が支配的だと実証できてから Mini に切り替えるのが YAGNI に適います。Mini は英語ロケール未同梱なので、エラー文言は
z.config(...)で明示設定が必要です。
13. ベストプラクティス&アンチパターン
| やるべきこと(Do) | 避けるべきこと(Don't) |
|---|---|
信頼境界(API/フォーム/env/外部 SDK)で1回 parse する | アプリ内部の型付き値を関数ごとに再検証する |
スキーマから z.infer で型を導出(単一の正) | type を手書きし、Zod スキーマと二重管理する |
| 同じスキーマをクライアントとサーバーの両端で使う | クライアントだけで検証し、サーバーは素通し |
外部入力は z.strictObject で想定外キーを弾く | 既定 strip 任せでマスアサインメントを許す |
エラーは flattenError / prettifyError で整形して返す | ZodError を生で画面・レスポンスに出す |
文字列は新形式 z.email() / z.iso.datetime() | 非推奨の z.string().email() を新規に書く |
env は起動時に safeParse で fail-fast | process.env.X! を as で握りつぶす |
変換は transform/pipe、前処理は preprocess と役割分担 | 1つの refine に検証も変換も詰め込む |
as を使わず parse で unknown を型に変換 | as User で「検証した気」になる |
14. FAQ(よくある質問)
Q. Zod と TypeScript の type / interface は何が違う?
A. type / interface はコンパイル時に消える静的な約束で、実行時には1バイトも検証しません。Zod は実行時に検証し、その検証から静的な型も導出します。外から来るデータには Zod、内部のロジックには TypeScript の型、と役割分担するのが正解です。
Q. Zod 4 で z.string().email() は使える?
A. 動きますが非推奨です。新形式の z.email()(同様に z.uuid() / z.url() / z.iso.datetime())を使ってください。短く、ツリーシェイクにも有利です。
Q. parse と safeParse はどちらを使うべき?
A. 例外で大域脱出したい簡易スクリプトなら parse、それ以外の実務(API・フォーム)では結果オブジェクトを返す safeParse が既定です。制御フローが素直になり、エラーを意図的に握りやすくなります。
Q. エラーメッセージを日本語にしたい。
A. import { ja } from "zod/locales" して z.config(ja()) をアプリ起動時に一度だけ呼べば、既定メッセージが日本語になります。個別メッセージは各スキーマの error パラメータで上書きできます。
Q. クライアントとサーバーで検証が二重になりませんか? A. それが正解です。スキーマ定義は1つにし、クライアント(UX のため即時表示)とサーバー(クライアントを信用しないため)の両端で適用します。定義は単一なのでズレません(DRY)。
Q. バンドルサイズが心配。Zod Mini を使うべき?
A. バックエンドなら不要、フロントもまず無印で計測してから判断してください。Zod 4 の無印はすでに高速・軽量です。バンドル分析で Zod が支配的だと実証できた場合のみ zod/mini に切り替えます(YAGNI)。
Q. Zod スキーマを OpenAPI や LLM の構造化出力に使える?
A. z.toJSONSchema() で JSON Schema を生成できます。OpenAPI 連携、LLM の structured output のスキーマ提示と出力の再検証(同じスキーマで parse)に使え、定義が一元化されます。
まとめ:スキーマは「アプリの信頼境界」である
Zod 4 を使いこなす鍵は、スキーマを「型を作る道具」ではなく「信頼できないデータを、信頼できる型へ変換する境界線」として設計することです。本記事の柱を振り返ると——
- "Parse, don't validate" ——
asをやめ、境界でparseしてunknownを型に変換する。 - 単一の正 ——スキーマ1つから
z.inferで型を導出し、二重管理を撲滅する。 - Zod 4 の新形式 ——
z.email()、error統一、flattenError/prettifyErrorでエラーを正しく扱う。 - 両端で守る ——同じスキーマを React Hook Form と API 境界の両方に適用する。
- 使い回す ——env の fail-fast、
z.toJSONSchema()による OpenAPI・LLM 連携まで、1つのスキーマを多用途に。
これらは小手先のテクニックではなく、型安全性・セキュリティ・保守性・回復性を同時に引き上げる「設計の選択」です。正しく適用すれば、ユーザー体験(的確なエラー表示)と開発体験(壊れにくい境界)の両方が底上げされます。
実際のプロダクトでは、ここに「境界の網羅」「エラーの観測(ロギング/監視)」「スキーマの版管理」まで含めて初めて本番品質になります。型安全を軸にした境界設計やレビュー、既存コードの堅牢化が必要な場合は、お気軽にご相談ください。 下記の事例では、業界の基幹業務を支える B2B SaaS を、型安全・回復性・保守性を重視して設計・実装した過程を紹介しています。