Zod完全ガイド2026 — TypeScript時代の最強バリデーションライブラリ


Zod完全ガイド2026 — TypeScript時代の最強バリデーションライブラリ

TypeScriptでバリデーションを書くとき、型定義とバリデーションロジックを2回書くのは無駄です。Zodなら、スキーマ定義から型を自動推論し、ランタイムバリデーションも同時に実現できます。

本記事では、Zodの基礎から応用、実践的な使い方まで徹底解説します。

Zodとは

Zodは、TypeScript-firstのスキーマ宣言とバリデーションライブラリです。

特徴:

  • 型推論: スキーマから TypeScript の型を自動生成
  • ゼロ依存: 軽量(約8KB gzip)
  • チェーン可能: .min(), .max(), .email() 等を繋げて書ける
  • エラーメッセージ: カスタマイズ可能
  • 変換(transform): バリデーション後にデータを加工可能

Zodをインストール

npm install zod

TypeScriptプロジェクトで使用(tsconfig.jsonstrict: true推奨)。

基本スキーマ

プリミティブ型

import { z } from 'zod';

// 文字列
const stringSchema = z.string();
stringSchema.parse('hello'); // OK
stringSchema.parse(123); // エラー

// 数値
const numberSchema = z.number();
numberSchema.parse(42); // OK

// 真偽値
const booleanSchema = z.boolean();
booleanSchema.parse(true); // OK

// null / undefined
const nullSchema = z.null();
const undefinedSchema = z.undefined();

// any(型安全性なし、非推奨)
const anySchema = z.any();

オブジェクト

const userSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  age: z.number().optional(), // オプショナル
});

// 型推論
type User = z.infer<typeof userSchema>;
// ↓ 自動生成される型
// type User = {
//   id: number;
//   name: string;
//   email: string;
//   age?: number;
// }

// バリデーション
const result = userSchema.parse({
  id: 1,
  name: 'Alice',
  email: 'alice@example.com',
});
// OK → result は User 型

userSchema.parse({
  id: 1,
  name: 'Bob',
  email: 'invalid-email', // エラー(emailバリデーション失敗)
});

配列

const numbersSchema = z.array(z.number());
numbersSchema.parse([1, 2, 3]); // OK
numbersSchema.parse([1, '2', 3]); // エラー

// 非空配列
const nonEmptySchema = z.array(z.string()).nonempty();
nonEmptySchema.parse([]); // エラー

// 最小・最大要素数
const limitedSchema = z.array(z.string()).min(1).max(10);

ユニオン型

const statusSchema = z.union([
  z.literal('pending'),
  z.literal('approved'),
  z.literal('rejected'),
]);

type Status = z.infer<typeof statusSchema>;
// type Status = "pending" | "approved" | "rejected"

statusSchema.parse('pending'); // OK
statusSchema.parse('unknown'); // エラー

// ショートハンド(enum)
const statusEnum = z.enum(['pending', 'approved', 'rejected']);

タプル

const tupleSchema = z.tuple([z.string(), z.number()]);

type Tuple = z.infer<typeof tupleSchema>;
// type Tuple = [string, number]

tupleSchema.parse(['Alice', 30]); // OK
tupleSchema.parse(['Alice']); // エラー(要素不足)

レコード

const recordSchema = z.record(z.string(), z.number());

type Record = z.infer<typeof recordSchema>;
// type Record = { [key: string]: number }

recordSchema.parse({ a: 1, b: 2 }); // OK
recordSchema.parse({ a: 1, b: 'invalid' }); // エラー

バリデーションルール

文字列

const schema = z.string()
  .min(3, '3文字以上必要です')
  .max(20, '20文字以下にしてください')
  .email('有効なメールアドレスを入力してください')
  .url('有効なURLを入力してください')
  .regex(/^[a-zA-Z0-9]+$/, '英数字のみ使用可能です')
  .trim() // 前後の空白を削除
  .toLowerCase(); // 小文字に変換

数値

const schema = z.number()
  .min(0, '0以上の値を入力してください')
  .max(100, '100以下の値を入力してください')
  .int('整数を入力してください')
  .positive('正の数を入力してください')
  .nonnegative('0以上の値を入力してください')
  .multipleOf(5, '5の倍数を入力してください');

日付

const dateSchema = z.date();

dateSchema.parse(new Date()); // OK
dateSchema.parse('2026-02-05'); // エラー(文字列はNG)

// 文字列をDateに変換
const dateStringSchema = z.string().pipe(z.coerce.date());
dateStringSchema.parse('2026-02-05'); // OK → Date オブジェクトに変換

// 日付範囲指定
const futureDate = z.date().min(new Date(), '未来の日付を指定してください');

カスタムバリデーション

const passwordSchema = z.string()
  .min(8, 'パスワードは8文字以上必要です')
  .refine(
    (val) => /[A-Z]/.test(val),
    '大文字を1文字以上含めてください'
  )
  .refine(
    (val) => /[a-z]/.test(val),
    '小文字を1文字以上含めてください'
  )
  .refine(
    (val) => /[0-9]/.test(val),
    '数字を1文字以上含めてください'
  );

passwordSchema.parse('Password1'); // OK
passwordSchema.parse('password'); // エラー(大文字と数字がない)

オプショナル・デフォルト値

オプショナル

const schema = z.object({
  name: z.string(),
  age: z.number().optional(), // age は省略可能
});

type User = z.infer<typeof schema>;
// type User = { name: string; age?: number }

schema.parse({ name: 'Alice' }); // OK
schema.parse({ name: 'Bob', age: 30 }); // OK

デフォルト値

const schema = z.object({
  name: z.string(),
  role: z.string().default('user'),
});

const result = schema.parse({ name: 'Alice' });
console.log(result);
// { name: 'Alice', role: 'user' }

nullable

const schema = z.string().nullable();

type Str = z.infer<typeof schema>;
// type Str = string | null

schema.parse('hello'); // OK
schema.parse(null); // OK
schema.parse(undefined); // エラー

nullish(nullable + optional)

const schema = z.string().nullish();

type Str = z.infer<typeof schema>;
// type Str = string | null | undefined

schema.parse('hello'); // OK
schema.parse(null); // OK
schema.parse(undefined); // OK

エラーハンドリング

parse vs safeParse

const schema = z.number();

// parse: エラーで例外をスロー
try {
  schema.parse('invalid');
} catch (err) {
  if (err instanceof z.ZodError) {
    console.error(err.errors);
  }
}

// safeParse: エラーをオブジェクトで返す(推奨)
const result = schema.safeParse('invalid');

if (!result.success) {
  console.error(result.error.errors);
  // [{ code: 'invalid_type', expected: 'number', received: 'string', path: [], message: '...' }]
} else {
  console.log(result.data); // 成功時のデータ
}

カスタムエラーメッセージ

const schema = z.object({
  email: z.string().email('有効なメールアドレスを入力してください'),
  age: z.number().min(18, '18歳以上である必要があります'),
});

const result = schema.safeParse({ email: 'invalid', age: 15 });

if (!result.success) {
  result.error.errors.forEach(err => {
    console.log(`${err.path.join('.')}: ${err.message}`);
  });
  // email: 有効なメールアドレスを入力してください
  // age: 18歳以上である必要があります
}

Zodとreact-hook-form連携

インストール

npm install react-hook-form @hookform/resolvers

フォームバリデーション

// UserForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const userSchema = z.object({
  name: z.string().min(1, '名前を入力してください'),
  email: z.string().email('有効なメールアドレスを入力してください'),
  age: z.number().min(18, '18歳以上である必要があります'),
});

type UserFormData = z.infer<typeof userSchema>;

export function UserForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<UserFormData>({
    resolver: zodResolver(userSchema),
  });

  const onSubmit = (data: UserFormData) => {
    console.log('送信データ:', data);
    // API送信処理
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>名前</label>
        <input {...register('name')} />
        {errors.name && <span>{errors.name.message}</span>}
      </div>

      <div>
        <label>メール</label>
        <input {...register('email')} />
        {errors.email && <span>{errors.email.message}</span>}
      </div>

      <div>
        <label>年齢</label>
        <input type="number" {...register('age', { valueAsNumber: true })} />
        {errors.age && <span>{errors.age.message}</span>}
      </div>

      <button type="submit">送信</button>
    </form>
  );
}

ネストしたオブジェクト

const addressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zipCode: z.string().regex(/^\d{3}-\d{4}$/, '郵便番号の形式が正しくありません'),
});

const userSchema = z.object({
  name: z.string(),
  address: addressSchema,
});

type UserFormData = z.infer<typeof userSchema>;

// フォームで使う
<input {...register('address.street')} />
<input {...register('address.city')} />
<input {...register('address.zipCode')} />

ZodとTanStack Query(React Query)連携

API レスポンスバリデーション

// api.ts
import { z } from 'zod';

const userSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

const usersSchema = z.array(userSchema);

export async function fetchUsers() {
  const res = await fetch('/api/users');
  const data = await res.json();

  // バリデーション
  return usersSchema.parse(data);
}
// UsersPage.tsx
import { useQuery } from '@tanstack/react-query';
import { fetchUsers } from './api';

export function UsersPage() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  if (isLoading) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error.message}</div>;

  return (
    <ul>
      {data?.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

API入力バリデーション(Next.js App Router)

Route Handler

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

const createUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.number().min(18),
});

export async function POST(req: NextRequest) {
  const body = await req.json();

  // バリデーション
  const result = createUserSchema.safeParse(body);

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

  // DB保存処理(省略)
  const user = result.data;

  return NextResponse.json({ user }, { status: 201 });
}

Server Actions

// app/actions.ts
'use server';

import { z } from 'zod';

const updateProfileSchema = z.object({
  name: z.string().min(1),
  bio: z.string().max(500),
});

export async function updateProfile(formData: FormData) {
  const rawData = {
    name: formData.get('name'),
    bio: formData.get('bio'),
  };

  const result = updateProfileSchema.safeParse(rawData);

  if (!result.success) {
    return { error: result.error.flatten() };
  }

  // DB更新処理
  const { name, bio } = result.data;

  return { success: true };
}
// app/profile/page.tsx
'use client';

import { updateProfile } from '../actions';

export default function ProfilePage() {
  async function handleSubmit(formData: FormData) {
    const result = await updateProfile(formData);

    if (result.error) {
      console.error(result.error);
    } else {
      console.log('更新成功');
    }
  }

  return (
    <form action={handleSubmit}>
      <input name="name" />
      <textarea name="bio" />
      <button>更新</button>
    </form>
  );
}

高度なテクニック

transform(データ変換)

const schema = z.string().transform((val) => val.toUpperCase());

const result = schema.parse('hello');
console.log(result); // "HELLO"

// 型も変換可能
const numberStringSchema = z.string().transform((val) => parseInt(val, 10));

type Result = z.infer<typeof numberStringSchema>;
// type Result = number

numberStringSchema.parse('42'); // 42(number型)

preprocess(前処理)

const schema = z.preprocess(
  (val) => (typeof val === 'string' ? val.trim() : val),
  z.string().min(1)
);

schema.parse('  hello  '); // "hello"(trim後にバリデーション)

discriminatedUnion(判別可能なユニオン)

const eventSchema = z.discriminatedUnion('type', [
  z.object({
    type: z.literal('click'),
    x: z.number(),
    y: z.number(),
  }),
  z.object({
    type: z.literal('keypress'),
    key: z.string(),
  }),
]);

type Event = z.infer<typeof eventSchema>;
// type Event = { type: 'click'; x: number; y: number } | { type: 'keypress'; key: string }

const event = eventSchema.parse({ type: 'click', x: 10, y: 20 });

if (event.type === 'click') {
  console.log(event.x, event.y); // 型が自動で絞り込まれる
}

extend(スキーマ拡張)

const baseUserSchema = z.object({
  id: z.number(),
  name: z.string(),
});

const adminUserSchema = baseUserSchema.extend({
  role: z.literal('admin'),
  permissions: z.array(z.string()),
});

type AdminUser = z.infer<typeof adminUserSchema>;
// type AdminUser = { id: number; name: string; role: 'admin'; permissions: string[] }

partial(すべてオプショナル)

const userSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

const partialUserSchema = userSchema.partial();

type PartialUser = z.infer<typeof partialUserSchema>;
// type PartialUser = { id?: number; name?: string; email?: string }

partialUserSchema.parse({}); // OK(すべて省略可能)

pick / omit(部分選択)

const userSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  password: z.string(),
});

// idとnameだけ取り出す
const publicUserSchema = userSchema.pick({ id: true, name: true });

// passwordを除外
const safeUserSchema = userSchema.omit({ password: true });

Zodのベストプラクティス

1. スキーマをファイル分割

// schemas/user.ts
import { z } from 'zod';

export const userSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

export type User = z.infer<typeof userSchema>;
// api.ts
import { userSchema } from './schemas/user';

export async function fetchUser(id: number) {
  const res = await fetch(`/api/users/${id}`);
  const data = await res.json();
  return userSchema.parse(data);
}

2. エラーメッセージをカスタマイズ

const schema = z.object({
  email: z.string({ required_error: 'メールアドレスは必須です' })
    .email('有効なメールアドレスを入力してください'),
  age: z.number({ required_error: '年齢は必須です' })
    .min(18, '18歳以上である必要があります'),
});

3. 環境変数バリデーション

// env.ts
import { z } from 'zod';

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
  PORT: z.string().transform((val) => parseInt(val, 10)),
});

export const env = envSchema.parse(process.env);

// 使用例
console.log(env.DATABASE_URL); // 型安全

まとめ — Zodを使うべき理由

従来のバリデーション(型定義とバリデーションが分離)

// 型定義
interface User {
  id: number;
  name: string;
  email: string;
}

// バリデーション(重複)
function validateUser(data: any): User {
  if (typeof data.id !== 'number') throw new Error('...');
  if (typeof data.name !== 'string') throw new Error('...');
  if (typeof data.email !== 'string') throw new Error('...');
  return data as User;
}

Zodを使う場合(1箇所で完結)

const userSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

type User = z.infer<typeof userSchema>; // 型は自動推論

const user = userSchema.parse(data); // バリデーション + 型安全

Zodの利点

  1. 型とバリデーションの一元管理: DRY原則
  2. 型推論: スキーマから自動で型生成
  3. ランタイム安全性: 外部データ(API、フォーム)を確実に検証
  4. エコシステム: react-hook-form、tRPC、Prisma等と連携
  5. 開発体験: エディタ補完が効く

2026年の採用状況

  • Next.js: 公式ドキュメントでZod推奨
  • tRPC: Zodがデフォルト
  • Remix: Zodを使った例が多数
  • shadcn/ui: フォームでZod使用

結論: TypeScriptプロジェクトでZodは標準ツール。必ず習得すべきライブラリです。


参考リンク: