※本記事には広告(楽天アフィリエイト)リンクが含まれる場合があります。
「フォームのバリデーションと型が二重管理になっている」「APIレスポンスをanyで受け取っていて不安」「Zodを導入したいけど何から始めればいいか分からない」——TypeScriptで開発していると、このような課題に必ずぶつかります。
ZodはTypeScriptファーストなスキーマ宣言・バリデーションライブラリです。スキーマを一度書けばそこから型が自動で導出されるため、型定義とバリデーションロジックが完全に同期した状態を保てます。React Hook Form・tRPC・Prisma・T3 Stackなど主要なエコシステムとの連携も充実しており、2026年現在で個人開発者からエンタープライズまで広く採用されています。
本記事では、Zodのインストールから基本的なスキーマ定義、高度な型の扱い、実際のユースケース(React Hook Form連携・APIレスポンス検証・環境変数バリデーション)まで、すぐ使い始められる実例を交えて解説します。
Zodとは何か・なぜ選ばれるのか
ZodはCollin McDonnellが開発したTypeScriptファーストのスキーマ検証ライブラリです。2020年のリリース以来、週間ダウンロード数が2000万を超え、現在TypeScriptエコシステムで最も広く使われているバリデーションライブラリのひとつとなっています。
従来のアプローチでは、TypeScriptの型定義とJoiやYupなどのランタイムバリデーションを別々に管理する必要がありました。Zodはスキーマ定義から型を導出できるため、型とバリデーションが常に一致するという根本的な問題を解決します。
| ライブラリ | 型の自動導出 | バンドルサイズ | エコシステム連携 | 学習コスト |
|---|---|---|---|---|
| Zod | あり(z.infer) |
約13KB(gzip) | 非常に豊富 | 低〜中 |
| Yup | 部分的 | 約29KB(gzip) | 中程度 | 低 |
| Valibot | あり | 約1KB〜(モジュール単位) | 成長中 | 中 |
| ArkType | あり(文字列シンタックス) | 約10KB(gzip) | 限定的 | 高 |
ZodはtRPCやPrismaのバリデーションレイヤー、T3 Stack(Next.js + tRPC + Prisma)のコアとして採用されており、エコシステムへの統合が突出しています。個人開発でこれらのツールを使う場合、Zodを学んでおくと連携がそのまま活きます。
インストールと最初のスキーマ
Zodのインストールは1コマンドです。TypeScript 4.5以上・strict modeが前提となります。
npm install zod
# または
pnpm add zod
最初のスキーマとして、ユーザー登録フォームを定義してみます。
import { z } from 'zod'
const UserSchema = z.object({
name: z.string().min(1, '名前は必須です').max(50),
email: z.string().email('有効なメールアドレスを入力してください'),
age: z.number().int().min(18, '18歳以上である必要があります').optional(),
})
// スキーマから型を導出
type User = z.infer<typeof UserSchema>
// {
// name: string;
// email: string;
// age?: number | undefined;
// }
// バリデーション
const result = UserSchema.safeParse({
name: 'Yui',
email: 'yui@example.com',
age: 25,
})
if (result.success) {
console.log(result.data) // 型付きデータ
} else {
console.log(result.error.flatten()) // エラー詳細
}
z.infer<typeof Schema> でスキーマから型が自動導出されます。以降は型定義を別途書く必要はありません。
プリミティブ型とバリデーション制約
Zodはすべての基本型に対応しており、チェーンでバリデーションを追加できます。
// 文字列
const nameSchema = z.string()
.min(1) // 最小長
.max(100) // 最大長
.trim() // トリム(前後の空白除去)
.toLowerCase() // 小文字化(変換)
// 数値
const priceSchema = z.number()
.positive() // 正の数
.multipleOf(0.01) // 小数点2桁まで
// 日付
const dateSchema = z.coerce.date() // 文字列・数値から Date に変換
// enum
const StatusSchema = z.enum(['active', 'inactive', 'pending'])
type Status = z.infer<typeof StatusSchema> // 'active' | 'inactive' | 'pending'
// リテラル
const TrueSchema = z.literal(true)
// boolean
const flagSchema = z.boolean()
// null / undefined
const maybeNull = z.null()
const maybeUndefined = z.undefined()
// any / unknown(型安全に絞り込む際の中間形)
const rawSchema = z.unknown()
オブジェクトスキーマの設計
実際の開発では複雑なオブジェクト構造を扱います。Zodはネスト・拡張・マージをシンプルなAPIで行えます。
const AddressSchema = z.object({
zip: z.string().regex(/^\d{3}-\d{4}$/, '郵便番号の形式が正しくありません'),
prefecture: z.string(),
city: z.string(),
street: z.string().optional(),
})
const UserProfileSchema = z.object({
id: z.string().uuid(),
username: z.string().min(3).max(20),
address: AddressSchema, // ネストしたスキーマ
})
// スキーマの拡張(フィールドを追加)
const AdminProfileSchema = UserProfileSchema.extend({
role: z.enum(['admin', 'superadmin']),
permissions: z.array(z.string()),
})
// 特定フィールドをオプショナルに
const PartialUserSchema = UserProfileSchema.partial()
// 特定フィールドだけ必須のまま残す
const PartialWithRequired = UserProfileSchema.partial({
address: true, // address だけ partial にする
})
// スキーマから一部フィールドを取り出す・除外する
const PublicProfile = UserProfileSchema.pick({ id: true, username: true })
const WithoutId = UserProfileSchema.omit({ id: true })
既知のキー以外を受け取った場合のデフォルト動作は「余剰キーを削除して返す(strip)」です。変更したい場合は .passthrough()(余剰キーを保持)や .strict()(余剰キーがあればエラー)を使います。
配列・タプル・Union型
コレクション型も直感的に定義できます。
// 配列
const TagsSchema = z.array(z.string()).min(1).max(10)
// タプル(固定長・要素ごとに型が異なる)
const CoordSchema = z.tuple([z.number(), z.number()]) // [lat, lng]
type Coord = z.infer<typeof CoordSchema> // [number, number]
// Union(どちらかの型)
const StringOrNumber = z.union([z.string(), z.number()])
// discriminatedUnion(判別可能なUnion・パフォーマンスが良い)
const EventSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('click'), x: z.number(), y: z.number() }),
z.object({ type: z.literal('keydown'), key: z.string() }),
z.object({ type: z.literal('scroll'), delta: z.number() }),
])
type Event = z.infer<typeof EventSchema>
// intersection(すべての型を満たす)
const NamedThing = z.object({ name: z.string() })
const TimestampedThing = z.object({ createdAt: z.date() })
const NamedTimestamped = z.intersection(NamedThing, TimestampedThing)
// Record(キーと値の型を指定する辞書)
const ScoreMap = z.record(z.string(), z.number())
type ScoreMap = z.infer<typeof ScoreMap> // Record<string, number>
バリデーションとエラーハンドリング
Zodのパースには2通りの方法があります。
const schema = z.object({ name: z.string(), age: z.number() })
// parse: 失敗すると ZodError をスロー(async では parseAsync)
try {
const data = schema.parse({ name: 'Alice', age: 'not a number' })
} catch (err) {
if (err instanceof ZodError) {
console.log(err.errors)
// [{ code: 'invalid_type', expected: 'number', received: 'string', path: ['age'], message: 'Expected number, received string' }]
}
}
// safeParse: 失敗してもスローせず { success, data | error } を返す
const result = schema.safeParse({ name: 'Alice', age: 30 })
if (result.success) {
result.data // { name: string; age: number }
} else {
result.error.flatten()
// {
// formErrors: [],
// fieldErrors: { age: ['Expected number, received string'] }
// }
}
// カスタムエラーメッセージ
const strict = z.string().min(8, { message: 'パスワードは8文字以上必要です' })
flatten() はReact Hook Formやサーバーアクションでフォームエラーを表示する際に便利です。フィールドごとのエラー配列が得られます。
transform と preprocess で値を変換する
バリデーションと同時にデータを変換したい場合、transform と preprocess が使えます。
// transform: バリデーション後に変換
const SlugSchema = z.string()
.min(1)
.transform(val => val.toLowerCase().replace(/\s+/g, '-'))
type Slug = z.infer<typeof SlugSchema> // string
const slug = SlugSchema.parse('Hello World') // 'hello-world'
// preprocess: バリデーション前に変換(型変換に便利)
const NumberFromStringSchema = z.preprocess(
val => (typeof val === 'string' ? parseFloat(val) : val),
z.number()
)
NumberFromStringSchema.parse('3.14') // 3.14
// coerce: 標準的な型変換ショートカット
const CoercedDate = z.coerce.date()
CoercedDate.parse('2026-01-01') // Date オブジェクト
CoercedDate.parse(1700000000000) // Unix タイムスタンプからも変換
// refine: カスタムバリデーション
const PasswordSchema = z.object({
password: z.string().min(8),
confirm: z.string(),
}).refine(data => data.password === data.confirm, {
message: 'パスワードが一致しません',
path: ['confirm'],
})
React Hook Form との連携
Zodは @hookform/resolvers 経由でReact Hook Formと連携できます。フォームのバリデーションとTypeScriptの型が自動で同期されます。
npm install react-hook-form @hookform/resolvers
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const LoginSchema = z.object({
email: z.string().email('有効なメールアドレスを入力してください'),
password: z.string().min(8, 'パスワードは8文字以上必要です'),
})
type LoginForm = z.infer<typeof LoginSchema>
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(LoginSchema),
})
const onSubmit = (data: LoginForm) => {
// data は LoginForm 型で型付け済み
console.log(data)
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register('email')} placeholder="メールアドレス" />
{errors.email && <p>{errors.email.message}</p>}
</div>
<div>
<input {...register('password')} type="password" placeholder="パスワード" />
{errors.password && <p>{errors.password.message}</p>}
</div>
<button type="submit">ログイン</button>
</form>
)
}
スキーマを変更するだけで型とバリデーションが両方更新されます。これがZod連携の最大の利点です。
APIレスポンスのバリデーション
外部APIのレスポンスは型が保証されていないため、anyで受け取ってしまいがちです。Zodを使えばランタイムで実際の型を検証しながら型安全なデータを取得できます。
import { z } from 'zod'
const PostSchema = z.object({
id: z.number(),
title: z.string(),
body: z.string(),
userId: z.number(),
})
const PostsSchema = z.array(PostSchema)
async function fetchPosts() {
const res = await fetch('https://jsonplaceholder.typicode.com/posts')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = await res.json()
const result = PostsSchema.safeParse(json)
if (!result.success) {
console.error('APIレスポンスの形式が想定と異なります:', result.error.flatten())
throw new Error('Invalid API response')
}
return result.data // Post[] 型で返る
}
// Next.js App Router のServer Actionでも同様のパターン
export async function createPost(formData: FormData) {
const rawData = Object.fromEntries(formData)
const result = PostSchema.omit({ id: true }).safeParse(rawData)
if (!result.success) {
return { errors: result.error.flatten().fieldErrors }
}
// result.data は型付き済み
// await db.posts.create({ data: result.data })
}
環境変数のバリデーション(t3-env)
環境変数のタイポや未設定は実行時まで気づきにくい問題です。t3-env を使うと、Zodスキーマで環境変数を定義してビルド時に検証できます。
npm install @t3-oss/env-nextjs # Next.js の場合
# または
npm install @t3-oss/env-core # フレームワーク非依存
// src/env.ts
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
SECRET_KEY: z.string().min(32),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_GA_ID: z.string().optional(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
SECRET_KEY: process.env.SECRET_KEY,
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_GA_ID: process.env.NEXT_PUBLIC_GA_ID,
},
})
// 使用時は型付きアクセス
import { env } from '@/env'
const dbUrl: string = env.DATABASE_URL
環境変数が未設定の場合はアプリ起動時にエラーが発生するため、デプロイ前に必ず気づけます。
高度なパターン:ブランド型・lazy・非同期バリデーション
Zodには実践的なシーンで活きる高度な機能もあります。
// ブランド型(プリミティブを区別する)
const UserId = z.string().uuid().brand('UserId')
type UserId = z.infer<typeof UserId> // string & { _brand: 'UserId' }
const PostId = z.string().uuid().brand('PostId')
type PostId = z.infer<typeof PostId>
function getUser(id: UserId) { /* ... */ }
// getUser(postId) // コンパイルエラー
// lazy(再帰的なスキーマ)
type Category = {
name: string
subcategories: Category[]
}
const CategorySchema: z.ZodType<Category> = z.lazy(() =>
z.object({
name: z.string(),
subcategories: z.array(CategorySchema),
})
)
// 非同期バリデーション(DBルックアップなど)
const UniqueEmailSchema = z.string().email().superRefine(async (val, ctx) => {
const exists = await checkEmailExists(val)
if (exists) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'このメールアドレスは既に登録されています',
})
}
})
// 使用時は parseAsync
const result = await UniqueEmailSchema.parseAsync('user@example.com')
Valibot・ArkTypeとの比較
Zodの代替として注目されている2つのライブラリの特徴を整理します。
| 観点 | Zod | Valibot | ArkType |
|---|---|---|---|
| バンドルサイズ | 13KB(gzip) | 1KB〜(使った分だけ) | 10KB(gzip) |
| APIスタイル | チェーンメソッド | パイプライン関数 | TypeScript風文字列 |
| tRPC連携 | 公式対応 | 対応(v5〜) | 限定的 |
| React Hook Form | 公式サポート | 対応 | 対応 |
| エコシステム | 最大 | 成長中 | 小規模 |
| こんな人に向く | エコシステム連携重視 | バンドルサイズ重視 | 型推論の正確さ重視 |
新規プロジェクトでのおすすめは以下の通りです。
- tRPC・T3 Stackを使う → Zod(互換性が最高)
- エッジ環境・モバイルファーストでバンドルサイズが最優先 → Valibot
- TypeScriptの型推論を極限まで活かしたい上級者 → ArkType
よくあるパターン集
実際のプロジェクトで頻繁に使うパターンをまとめます。
// 1. ISO日付文字列を Date に変換
const ISODateSchema = z.string().datetime().pipe(z.coerce.date())
// 2. 空文字列を undefined として扱う
const OptionalString = z.string().transform(val => val === '' ? undefined : val)
// 3. フォームの checkbox(文字列 'on' / undefined)を boolean に変換
const CheckboxSchema = z.preprocess(
val => val === 'on',
z.boolean()
)
// 4. JSON文字列をパースして型付け
const JsonUserSchema = z.string().transform((str, ctx) => {
try {
return JSON.parse(str) as unknown
} catch {
ctx.addIssue({ code: 'custom', message: '有効なJSONではありません' })
return z.NEVER
}
}).pipe(UserSchema)
// 5. URLのクエリパラメータを数値に変換
const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
// URL: ?page=2&limit=50 → { page: 2, limit: 50 }
Zodを使いこなすためのベストプラクティス
- スキーマはモジュール分割して再利用する:
schemas/user.tsのように専用ファイルに切り出す - parse より safeParse を優先する:サーバーアクションやAPIルートでは例外を意図せずスローしないよう
safeParseを使う - 型のエクスポートは infer で行う:型定義を二重管理せず常に
z.inferから型を導出する - バリデーションはシステム境界で行う:入力(フォーム・APIレスポンス・環境変数)でのみ検証し、内部ロジックでは型を信頼する
- カスタムエラーメッセージはユーザー向けに書く:技術的な文言ではなく「〜を入力してください」のようなUX寄りのメッセージにする
まとめ
Zodは「TypeScriptの型」と「ランタイムバリデーション」の乖離という根本的な問題を解決するライブラリです。スキーマを一度書けば型が自動導出され、React Hook Form・tRPC・T3 Stackといったエコシステムともシームレスに連携します。
本記事で紹介した主なポイントを振り返ります。
- スキーマから
z.inferで型を導出することで型の二重管理をなくす safeParseで例外を使わないエラーハンドリングを実現するtransformとcoerceでバリデーションと同時にデータ変換を行う- React Hook Formの
zodResolverでフォームの型安全を確保する - t3-envで環境変数をビルド時に検証する
- APIレスポンスは必ずZodで検証することで実行時エラーを防ぐ
まずは既存のフォームバリデーションをZodに置き換えるところから始めてみましょう。小さな変更でも型の恩恵をすぐに実感できます。