🙌

【TS/JS】 Zodライブラリで始めるデータ検証とスキーマ宣言まとめ

に公開

Zodは、TypeScript/JavaScript環境でデータの検証を行い、スキーマ宣言を通じて型安全性を強化するライブラリです。

Zodの役割

  1. ランタイムデータ検証

    • APIリクエストボディ、URLパラメータ、環境変数など、外部から受け取るデータは信頼できない
    • Zodを利用することで、予期しない値(型不一致・必須値の欠落など)を事前に排除できる
  2. 型の自動生成

    • ZodスキーマからTypeScriptの型を推論可能
    • 型宣言と検証ロジックを別々に記述する必要がなくなる
  3. エラーメッセージの提供

    • どのフィールドが、なぜ失敗したのかを明確に把握できる
  4. メンテナンスしやすい

    • 「スキーマ = 型定義 + バリデーション」という形で一元管理できるので、あとから仕様が変わっても修正がしやすい

設置

npm install zod       # npm
yarn add zod          # yarn

基本文法

import { z } from "zod";

const UserSchema = z.object({
  // ① まず基本の型を指定
  name: z.string(),

  // ② その型に対してチェーンで条件を追加
  age: z.number().int().positive(), // 整数 & 正の数

  // ③ emailも同様
  email: z.string().email(),
});


// データ検証
const result = UserSchema.safeParse({
  name: "山田太郎",
  age: 25,
  email: "test@example.com"
});


if (result.success) {
  console.log("✅ 有効なデータ:", result.data);
} else {
  console.error("❌ 検証失敗:", result.error.format());
}

簡単な例と結果

// ログイン情報の検証例
import { z } from "zod";

//エラーメッセージは第2引数に直接渡せる
const LoginSchema = z.object({
  email: z.string().email("メール形式ではありません。"),
  password: z.string().min(6, "パスワードは6文字以上で入力してください。"),
});

const input = {
  email: "abc.com", // 不正なメール形式
  password: "123"   // 短すぎる
};

const result = LoginSchema.safeParse(input);

if (!result.success) {
  console.log(result.error.format());
}
/* 結果 */

{
  "email": { "_errors": ["メール形式ではありません。"] },
  "password": { "_errors": ["パスワードは6文字以上で入力してください。"] }
}

Zod 実践チートシート(よく使う文法)

以下、チャットGPTまとめになります。

0) 基本パターン

import { z } from "zod";

// ① スキーマ宣言
const UserSchema = z.object({
  name: z.string().min(1, "名前は必須です。"),
  email: z.string().email("メール形式ではありません。"),
  age: z.coerce.number().int().positive("正の整数で入力してください。"),
});

// ② 検証 & エラーハンドリング
const r = UserSchema.safeParse(input);
if (!r.success) return { errors: r.error.format() };
const user = r.data; // 型安全に利用可能

// ③ 型推論
type User = z.infer<typeof UserSchema>;

1) 基本型(文字列・数値・真偽値・日付)

// string
z.string({ required_error: "必須です。" })
 .min(1, "1文字以上")
 .max(50, "50文字以下")
 .email("メール形式ではありません。")
 .url("URL形式ではありません。")
 .uuid("UUID形式ではありません。")
 .regex(/^[a-z0-9-]+$/i, "半角英数字と-のみ");

// number
z.number({ invalid_type_error: "数値で入力してください。" })
 .int("整数で入力してください。")
 .positive("正の数のみ")
 .gte(0, "0以上")
 .lte(100, "100以下");

// boolean / date
z.boolean({ invalid_type_error: "true/falseを指定してください。" });
z.date().min(new Date("2025-01-01"), "2025-01-01以降の日付");

2) optional / nullable / default

z.string().optional();     // undefinedを許可
z.string().nullable();     // nullを許可
z.string().nullish();      // null | undefinedを許可
z.string().default("guest"); // デフォルト値

3) 配列・レコード・オブジェクト

// 配列
z.array(z.string()).nonempty("1件以上入力してください。");

// レコード型 { [key: string]: number }
z.record(z.string(), z.number());

// オブジェクトの余計なキーをどうするか
z.object({ name: z.string() }).strict();      // 定義外キーはエラー
z.object({ name: z.string() }).passthrough(); // 定義外キーも許可(デフォルト)
z.object({ name: z.string() }).strip();       // 定義外キーは削除

4) スキーマ再利用

const BaseUser = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

const CreateUser = BaseUser.extend({ password: z.string().min(8) });
const UpdateUser = BaseUser.partial();                 // 全てoptionalに
const PublicUser = BaseUser.omit({ email: true });     // emailを除外
const EmailOnly  = BaseUser.pick({ email: true });     // emailだけ
const AdminUser  = BaseUser.merge(z.object({ role: z.literal("admin") }));

5) 文字列 → 数値・真偽値(coerce)

const QuerySchema = z.object({
  page: z.coerce.number().int().gte(1).default(1),
  perPage: z.coerce.number().int().min(1).max(100).default(20),
  published: z.coerce.boolean().default(false),
});

6) transform:検証 + 加工

// 価格(円) → セントに変換
const PriceSchema = z.coerce.number().min(0)
  .transform(v => Math.round(v * 100));

// 空白を削除し、空文字は禁止
const NonEmptyTrimmed = z.string()
  .transform(s => s.trim())
  .refine(s => s.length > 0, { message: "空白のみは不可" });

7) refine / superRefine:ビジネスルール

// 単一フィールド
const Password = z.string().min(8)
  .refine(v => /[0-9]/.test(v), { message: "数字を含めてください。" });

// 複数フィールド
const Signup = z.object({
  password: z.string().min(8),
  confirm : z.string().min(8),
}).superRefine((data, ctx) => {
  if (data.password !== data.confirm) {
    ctx.addIssue({
      code: "custom",
      path: ["confirm"],
      message: "パスワードが一致しません。",
    });
  }
});

8) Union / Discriminated Union / Intersection

// Union
const Result = z.union([z.string(), z.number()]);

// 判別ユニオン
const Event = z.discriminatedUnion("type", [
  z.object({ type: z.literal("created"), id: z.string() }),
  z.object({ type: z.literal("deleted"), id: z.string(), reason: z.string().optional() }),
]);

// Intersection
const AB = z.intersection(
  z.object({ a: z.string() }),
  z.object({ b: z.number() })
);

9) Enum / nativeEnum

// Enum
const Role = z.enum(["admin", "user", "guest"]);
type Role = z.infer<typeof Role>;

// TypeScript enumと接続
enum Status { READY = "READY", DONE = "DONE" }
const StatusSchema = z.nativeEnum(Status);

10) APIルートごとのスキーマ

// Body
const Body = z.object({
  name: z.string({ required_error: "名前は必須です。" }).min(1),
  email: z.string().email(),
});

// Query
const Query = z.object({
  page: z.coerce.number().int().gte(1).default(1),
});

// Params (/users/[id])
const Params = z.object({
  id: z.string().uuid("UUID形式で指定してください。"),
});

// Headers
const Headers = z.object({
  authorization: z.string().startsWith("Bearer ", "Bearerトークンが必要です。"),
});

// Env
const Env = z.object({
  NODE_ENV: z.enum(["development", "test", "production"]),
  DATABASE_URL: z.string().url(),
}).parse(process.env);

11) エラーハンドリングパターン

function validate<T extends z.ZodTypeAny>(schema: T, data: unknown) {
  const r = schema.safeParse(data);
  return r.success
    ? { data: r.data, errors: null }
    : { data: null, errors: r.error.format() };
}

12) Next.js Route 実践例

import { z } from "zod";
import { NextRequest, NextResponse } from "next/server";

const Body = z.object({
  name: z.string({ required_error: "名前は必須です。" }).min(1, "名前は必須です。"),
  email: z.string().email("メール形式ではありません。"),
  age: z.coerce.number().int().positive("正の整数で入力してください。").optional(),
});

export async function POST(req: NextRequest) {
  const json = await req.json().catch(() => null);
  const parsed = Body.safeParse(json);

  if (!parsed.success) {
    return NextResponse.json({ errors: parsed.error.format() }, { status: 400 });
  }

  const body = parsed.data;
  // …DB処理など
  return NextResponse.json({ ok: true });
}

✅ まとめ

  • 基本型.min(), .max(), .email(), .positive() などでメッセージ指定
  • optional / nullable / default で入力の柔軟性
  • coerce で文字列 → 数値/boolean 変換(フォーム・URLで超便利)
  • refine / superRefine でビジネスルールチェック
  • extend / pick / omit / partial / merge でスキーマ再利用
  • safeParse + error.format() が定番パターン
  • z.setErrorMap を使えばプロジェクト全体のエラーメッセージを統一可能

Discussion