



WithCodeMedia-1-pc
WithCodeMedia-2-pc
WithCodeMedia-3-pc
WithCodeMedia-4-pc




WithCodeMedia-1-sp
WithCodeMedia-2-sp
WithCodeMedia-3-sp
WithCodeMedia-4-sp








結論から言うと、ZodはTypeScriptの型安全性をランタイムまで延伸する実践的なライブラリです。TypeScriptの型はJavaScriptにコンパイルされる際に消えるため、APIレスポンスの型が実際に正しいかはランタイムでしか検証できません。Zodのスキーマはランタイムで動作し、z.inferでTypeScriptの型も自動生成できるため、型定義とバリデーションの二重管理が不要になります。
>// TypeScriptの型定義(コンパイル後は消える)
interface User {
id: number
name: string
email: string
}
// APIから受け取ったデータを型アサーションで扱う(危険!)
const response = await fetch('/api/users/1')
const user = await response.json() as User // ← 実際のデータ形式を確認していない
// もしAPIが { id: "123", name: null } を返しても TypeScript は気づけない
// → user.name.toUpperCase() でランタイムエラー!
// Zodを使ったランタイム安全な実装
import { z } from 'zod'
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
})
const result = UserSchema.safeParse(await response.json())
if (!result.success) {
// バリデーションエラーを型安全にハンドリング
console.error(result.error.flatten())
return
}
const user = result.data // 型: { id: number; name: string; email: string }
>npm install zod
# or
pnpm add zod
yarn add zod
>import { z } from 'zod'
// Zod スキーマを定義(ランタイムで動作するバリデーションルール)
const UserSchema = z.object({
id: z.number(),
name: z.string().min(1, '名前は必須です'),
email: z.string().email('有効なメールアドレスを入力してください'),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(['admin', 'user', 'guest']).default('user'),
})
// z.infer でスキーマからTypeScriptの型を自動生成
// → interface の二重定義が不要!
export type User = z.infer
// type User = {
// id: number;
// name: string;
// email: string;
// age?: number | undefined;
// role: "admin" | "user" | "guest";
// }
| メソッド | 失敗時の挙動 | 推奨用途 |
|---|---|---|
| parse() | ZodErrorをスロー(try/catch必要) | 確実に正しいデータが来るはずの場所 |
| safeParse() | { success, data, error } を返す | APIレスポンス・フォームデータ(推奨) |
| parseAsync() | Promiseを返しエラー時にreject | 非同期バリデーション(DBチェックなど) |
>// safeParse() : エラーをスローせず、success/error で分岐できる(推奨)
const result = UserSchema.safeParse({
id: 'not-a-number', // ← 不正な値
name: '',
email: 'invalid-email',
})
if (result.success) {
console.log('バリデーション成功:', result.data)
} else {
// flatten() でフォームエラー表示に最適な形式に変換
const flattened = result.error.flatten()
console.log(flattened.fieldErrors)
// → { id: ['Expected number, received string'], name: ['名前は必須です'], email: [...] }
}
>import { z } from 'zod'
// 文字列型
z.string()
z.string().min(1) // 最小長
z.string().max(100) // 最大長
z.string().length(10) // 固定長
z.string().email() // メールアドレス形式
z.string().url() // URL形式
z.string().uuid() // UUID形式
z.string().regex(/^[A-Z]+$/) // 正規表現
z.string().startsWith('https://') // プレフィックス
z.string().endsWith('.jpg') // サフィックス
z.string().includes('admin') // 文字列を含む
z.string().trim() // 前後の空白を除去(変換)
z.string().toLowerCase() // 小文字に変換
z.string().toUpperCase() // 大文字に変換
// 数値型
z.number()
z.number().min(0) // 最小値
z.number().max(100) // 最大値
z.number().int() // 整数のみ
z.number().positive() // 正数のみ(> 0)
z.number().negative() // 負数のみ(< 0)
z.number().nonnegative() // 非負(>= 0)
z.number().finite() // 有限数(Infinity除外)
z.number().safe() // Number.MIN/MAX_SAFE_INTEGER 範囲内
// Boolean型
z.boolean()
// BigInt型
z.bigint()
z.bigint().min(0n)
// Symbol型
z.symbol()
// Null/Undefined
z.null()
z.undefined()
z.void() // undefined を返す関数用
z.any() // any(バリデーションをスキップ)
z.unknown() // unknown(バリデーションはしないが型は unknown)
z.never() // never(値を許可しない)
>// オブジェクト型
const PersonSchema = z.object({
name: z.string(),
age: z.number(),
})
// 余分なキーの扱い
z.object({ name: z.string() }).strict() // 余分なキーはエラー
z.object({ name: z.string() }).strip() // 余分なキーを除去(デフォルト)
z.object({ name: z.string() }).passthrough() // 余分なキーをそのまま通す
// 部分型
PersonSchema.partial() // 全フィールドをoptionalに
PersonSchema.partial({ age: true }) // 特定フィールドのみoptionalに
PersonSchema.required() // 全フィールドをrequiredに
PersonSchema.pick({ name: true }) // 特定フィールドのみ選択
PersonSchema.omit({ age: true }) // 特定フィールドを除外
PersonSchema.extend({ email: z.string().email() }) // フィールドを追加
// マージ
const ExtendedPersonSchema = PersonSchema.merge(
z.object({ email: z.string().email() })
)
// 配列型
z.array(z.string()) // 文字列の配列
z.array(z.number()).min(1) // 1件以上
z.array(z.string()).max(10) // 10件以下
z.array(z.string()).length(3) // 固定件数
z.array(z.string()).nonempty() // 空配列を許可しない
// タプル型(固定長・各要素が異なる型)
const TupleSchema = z.tuple([z.string(), z.number(), z.boolean()])
// type: [string, number, boolean]
// タプルの残余引数
const VariadicTuple = z.tuple([z.string(), z.number()]).rest(z.string())
// type: [string, number, ...string[]]
>// Union(どれか1つに一致)
const StringOrNumber = z.union([z.string(), z.number()])
// or 短縮記法
const StringOrNumber2 = z.string().or(z.number())
// Literal(特定の値のみ)
z.literal('admin')
z.literal(42)
z.literal(true)
// Enum(文字列リテラルの集合)
const RoleSchema = z.enum(['admin', 'user', 'guest'])
type Role = z.infer // → 'admin' | 'user' | 'guest'
// TypeScriptのenumとの連携
enum Status { Active = 'active', Inactive = 'inactive' }
const StatusSchema = z.nativeEnum(Status)
// Discriminated Union(判別式付き共用体)
// APIのレスポンスが成功/失敗で構造が異なる場合
const ApiResultSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('success'),
data: z.object({ id: z.number(), name: z.string() }),
}),
z.object({
type: z.literal('error'),
code: z.number(),
message: z.string(),
}),
])
type ApiResult = z.infer
// 使用例
const result = ApiResultSchema.parse({ type: 'success', data: { id: 1, name: 'Taro' } })
if (result.type === 'success') {
console.log(result.data.name) // 型安全: result.data が保証される
} else {
console.error(result.message) // 型安全: result.message が保証される
}
// Intersection(すべてに一致)
const AdminUser = z.object({ role: z.literal('admin') }).and(PersonSchema)
// type: { role: 'admin'; name: string; age: number }
>import { z } from 'zod'
// refine():カスタムバリデーションを1つ追加
const PasswordSchema = z
.string()
.min(8, 'パスワードは8文字以上')
.refine(
(val) => /[A-Z]/.test(val), // 大文字を含む
'大文字を1文字以上含めてください'
)
.refine(
(val) => /[0-9]/.test(val), // 数字を含む
'数字を1文字以上含めてください'
)
// クロスフィールドバリデーション(パスワード確認)
const RegisterSchema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine(
(data) => data.password === data.confirmPassword,
{
message: 'パスワードが一致しません',
path: ['confirmPassword'], // エラーをどのフィールドに紐づけるか
}
)
// superRefine():複数のカスタムエラーを追加できる
const AdvancedSchema = z.object({
username: z.string(),
email: z.string(),
plan: z.enum(['free', 'pro']),
maxProjects: z.number(),
}).superRefine((data, ctx) => {
// プランごとのプロジェクト上限チェック
if (data.plan === 'free' && data.maxProjects > 3) {
ctx.addIssue({
code: z.ZodIssueCode.too_big,
path: ['maxProjects'],
message: 'Freeプランのプロジェクト上限は3件です',
maximum: 3,
inclusive: true,
type: 'number',
})
}
// 非同期バリデーション(DBチェック等)
// superRefine は async にもできる
})
>import { z } from 'zod'
// transform():バリデーション後にデータを変換する
const DateStringSchema = z
.string()
.datetime()
.transform((val) => new Date(val)) // 文字列をDateオブジェクトに変換
type DateValue = z.infer // → Date
// 複数の変換をチェーン
const UserSchema = z.object({
name: z.string().transform((val) => val.trim()), // 空白除去
email: z.string().email().transform((val) => val.toLowerCase()), // 小文字化
age: z.string().transform(Number), // 文字列から数値へ
})
// preprocess():バリデーション前にデータを前処理する
const NumberFromString = z.preprocess(
(val) => (typeof val === 'string' ? Number(val) : val),
z.number()
)
// "42" → 42(文字列が来ても数値として検証)
// coerce:自動型変換(最もシンプル)
const CoercedNumber = z.coerce.number() // "42" → 42
const CoercedBoolean = z.coerce.boolean() // "true" → true
const CoercedDate = z.coerce.date() // "2026-03-14" → Date
// フォームデータ(全てstring)からの変換に最適
const FormSchema = z.object({
age: z.coerce.number().min(0).max(150),
birthDate: z.coerce.date(),
subscribe: z.coerce.boolean(),
rating: z.coerce.number().int().min(1).max(5),
})
Zodの活用例として特に効果的なのが環境変数のバリデーションです。環境変数は全て文字列で渡されるため、coerceを活用して型変換しながら検証できます。アプリ起動時に一度だけ実行することで設定ミスを早期発見できます。
>// env.ts - 環境変数のバリデーション
import { z } from 'zod'
const EnvSchema = z.object({
// 必須の環境変数
DATABASE_URL: z.string().url('有効なデータベースURLが必要です'),
NEXTAUTH_SECRET: z.string().min(32, 'NEXTAUTH_SECRETは32文字以上必要です'),
OPENAI_API_KEY: z.string().startsWith('sk-', 'OpenAI APIキーは sk- で始まります'),
// オプションの環境変数(デフォルト値付き)
NODE_ENV: z
.enum(['development', 'production', 'test'])
.default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
REDIS_URL: z.string().url().optional(),
ENABLE_ANALYTICS: z.coerce.boolean().default(false),
MAX_UPLOAD_SIZE_MB: z.coerce.number().int().positive().default(10),
// 本番環境でのみ必須
SENTRY_DSN: z.string().url().optional(),
})
// アプリ起動時に1回だけ実行
const parseEnv = () => {
const result = EnvSchema.safeParse(process.env)
if (!result.success) {
console.error('❌ 環境変数の設定が不正です:')
result.error.errors.forEach((err) => {
console.error(` ${err.path.join('.')}: ${err.message}`)
})
process.exit(1) // 不正な環境変数ではアプリを起動させない
}
return result.data
}
export const env = parseEnv()
export type Env = typeof env
// 使用例
import { env } from './env'
console.log(env.PORT) // 型: number(coerceで変換済み)
console.log(env.NODE_ENV) // 型: "development" | "production" | "test"
>import { z } from 'zod'
// ページネーション付きAPIレスポンスのスキーマ
const PaginatedResponseSchema = (itemSchema: T) =>
z.object({
status: z.literal('success'),
data: z.object({
items: z.array(itemSchema),
pagination: z.object({
total: z.number().int().nonnegative(),
page: z.number().int().positive(),
perPage: z.number().int().positive(),
totalPages: z.number().int().nonnegative(),
hasNext: z.boolean(),
hasPrev: z.boolean(),
}),
}),
})
// 商品スキーマ
const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
price: z.number().nonnegative(),
currency: z.enum(['JPY', 'USD', 'EUR']),
category: z.enum(['electronics', 'clothing', 'food', 'other']),
inStock: z.boolean(),
tags: z.array(z.string()).default([]),
createdAt: z.coerce.date(),
})
export type Product = z.infer
// 型安全なAPI呼び出し関数
async function fetchProducts(
page: number = 1,
perPage: number = 20
): Promise>>['data']> {
const response = await fetch(`/api/products?page=${page}&per_page=${perPage}`)
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`)
}
const json = await response.json()
// ランタイムバリデーション
const parsed = PaginatedResponseSchema(ProductSchema).safeParse(json)
if (!parsed.success) {
// バリデーションエラーの詳細をログ出力
console.error('API response validation failed:', parsed.error.format())
throw new Error('Invalid API response format')
}
return parsed.data.data
}
// discriminated union で成功/失敗を型安全に扱う
const ResponseSchema = z.discriminatedUnion('status', [
z.object({ status: z.literal('success'), data: ProductSchema }),
z.object({
status: z.literal('error'),
code: z.enum(['NOT_FOUND', 'UNAUTHORIZED', 'INTERNAL_SERVER_ERROR', 'VALIDATION_ERROR']),
message: z.string(),
details: z.record(z.array(z.string())).optional(),
}),
])
async function fetchProduct(id: string) {
const json = await fetch(`/api/products/${id}`).then(r => r.json())
const result = ResponseSchema.parse(json)
if (result.status === 'error') {
// TypeScriptがresult.code, result.messageを認識
throw new Error(`${result.code}: ${result.message}`)
}
return result.data // TypeScriptがresult.dataをProductと認識
}
>import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
// フォームのバリデーションスキーマ
const RegisterSchema = z.object({
username: z
.string()
.min(3, 'ユーザー名は3文字以上で入力してください')
.max(20, 'ユーザー名は20文字以内で入力してください')
.regex(/^[a-zA-Z0-9_]+$/, '半角英数字とアンダースコアのみ使用できます'),
email: z.string().email('有効なメールアドレスを入力してください'),
password: z
.string()
.min(8, 'パスワードは8文字以上で入力してください')
.regex(/[A-Z]/, '大文字を1文字以上含めてください')
.regex(/[0-9]/, '数字を1文字以上含めてください'),
confirmPassword: z.string(),
birthDate: z.coerce.date().max(new Date(), '未来の日付は入力できません'),
plan: z.enum(['free', 'pro', 'enterprise'], {
errorMap: () => ({ message: 'プランを選択してください' }),
}),
agreeToTerms: z.literal(true, {
errorMap: () => ({ message: '利用規約への同意が必要です' }),
}),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'パスワードが一致しません',
path: ['confirmPassword'],
}
)
type RegisterForm = z.infer
export function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
watch,
setError,
} = useForm({
resolver: zodResolver(RegisterSchema),
mode: 'onChange', // 入力するたびにバリデーション
defaultValues: {
plan: 'free',
},
})
const onSubmit = async (data: RegisterForm) => {
try {
await registerUser(data)
} catch (error) {
// サーバーエラーをフォームエラーとして表示
setError('email', { message: 'このメールアドレスは既に登録されています' })
}
}
return (
)
}
>// app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
// クエリパラメーターのスキーマ
const SearchParamsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
perPage: z.coerce.number().int().min(1).max(100).default(20),
q: z.string().optional(),
category: z.enum(['electronics', 'clothing', 'food', 'other']).optional(),
sortBy: z.enum(['createdAt', 'price', 'name']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
})
// リクエストボディのスキーマ
const CreateProductSchema = z.object({
name: z.string().min(1).max(200),
price: z.number().positive().multipleOf(1), // 整数円
category: z.enum(['electronics', 'clothing', 'food', 'other']),
description: z.string().max(5000).optional(),
tags: z.array(z.string().max(30)).max(10).default([]),
})
export async function GET(request: NextRequest) {
// クエリパラメーターを検証
const searchParams = Object.fromEntries(request.nextUrl.searchParams)
const parseResult = SearchParamsSchema.safeParse(searchParams)
if (!parseResult.success) {
return NextResponse.json(
{
status: 'error',
code: 'VALIDATION_ERROR',
message: 'Invalid query parameters',
details: parseResult.error.flatten().fieldErrors,
},
{ status: 400 }
)
}
const { page, perPage, q, category, sortBy, order } = parseResult.data
const products = await db.products.findMany({ /* ... */ })
return NextResponse.json({ status: 'success', data: { items: products } })
}
export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null)
const parseResult = CreateProductSchema.safeParse(body)
if (!parseResult.success) {
return NextResponse.json(
{
status: 'error',
code: 'VALIDATION_ERROR',
details: parseResult.error.flatten().fieldErrors,
},
{ status: 422 }
)
}
const product = await db.products.create({ data: parseResult.data })
return NextResponse.json({ status: 'success', data: product }, { status: 201 })
}
>// Hono の @hono/zod-validator を使った実装
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const app = new Hono()
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
password: z.string().min(8),
})
// zodValidator ミドルウェアを使ってリクエストを自動検証
app.post(
'/api/users',
zValidator('json', CreateUserSchema, (result, c) => {
if (!result.success) {
return c.json(
{
status: 'error',
details: result.error.flatten().fieldErrors,
},
422
)
}
}),
async (c) => {
const { name, email, password } = c.req.valid('json') // 型安全
const user = await createUser({ name, email, password })
return c.json({ status: 'success', data: user }, 201)
}
)
// クエリパラメーターの検証
const QuerySchema = z.object({
limit: z.coerce.number().int().positive().default(20),
offset: z.coerce.number().int().nonnegative().default(0),
})
app.get(
'/api/users',
zValidator('query', QuerySchema),
async (c) => {
const { limit, offset } = c.req.valid('query') // 型安全
const users = await db.users.findMany({ take: limit, skip: offset })
return c.json({ data: users })
}
)
export default app
>【パターン1:Zodスキーマを「唯一の真実(Single Source of Truth)」とする】
// ✅ スキーマから型を生成してアプリ全体で使う
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
})
type User = z.infer // interfaceを別途書かない
// メリット
// → 型とバリデーションが常に一致
// → DRY(Don't Repeat Yourself)原則の徹底
// → スキーマを変更すれば型も自動的に変わる
// デメリット
// → Zodへの依存度が高まる
// → 複雑な型(条件型・mapped型)はZodで表現しにくい
【パターン2:Zodを「境界」でのみ使う(関心の分離)】
// ✅ 内部型は interface/type で定義(ドメインモデル)
interface User {
id: number
name: string
email: string
}
// ✅ APIとの境界部分でのみZodスキーマを使う(アダプター層)
const UserApiSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
})
function toUser(raw: unknown): User {
const parsed = UserApiSchema.parse(raw) // 検証して内部型に変換
return { id: parsed.id, name: parsed.name, email: parsed.email }
}
// メリット
// → ドメイン層がZod非依存(ポータビリティが高い)
// → 複雑な型もinterfaceで自由に表現できる
// デメリット
// → スキーマとinterfaceの二重定義が発生
// → 一方を変更したときに他方を更新し忘れるリスク
【推奨】
→ 小〜中規模:パターン1(スキーマが唯一の真実)
→ 大規模・ドメイン駆動設計:パターン2(境界でのみ使用)
| 比較項目 | Zod | Yup | Valibot |
|---|---|---|---|
| TypeScript連携 | ◎ z.inferで型自動生成 | △ 別途型定義が必要 | ◎ 型推論対応 |
| バンドルサイズ | △ 約14KB(gzip) | △ 約18KB(gzip) | ◎ 約1KB(tree-shaking) |
| React Hook Form連携 | ◎ @hookform/resolvers | ◎ @hookform/resolvers | ○ @hookform/resolvers |
| エコシステム | ◎ 最大(tRPC・drizzle等) | ○ 豊富 | △ 発展途上 |
| API | メソッドチェーン | メソッドチェーン | 関数合成スタイル |
| 非同期バリデーション | ○(parseAsync) | ◎(ネイティブ対応) | ○(parseAsync) |
z.inferでスキーマから型を生成すれば、interfaceの別途定義は不要です。type LoginForm = z.infer<typeof LoginSchema>とするだけでTypeScriptの型として機能します。interfaceとZodスキーマの二重管理はミスの原因になるため、パターン1(スキーマが唯一の真実)を採用する場合はz.inferのみを使うのがベストプラクティスです。
z.string().optional()はstring | undefinedの型を生成し、フィールドが存在しなくてもエラーにならない設定です。z.string().nullable()はstring | nullの型を生成し、フィールドがnullでもエラーにならない設定です。z.string().nullish()はstring | null | undefinedの型を生成し、どちらも許可します。APIの仕様に合わせて選択してください。
tRPCはZodとの組み合わせが前提設計になっています。プロシージャの入力スキーマとしてinput(z.object({...}))を定義するだけで、クライアント側とサーバー側の両方で型安全が確保されます。tRPCではZodスキーマから自動的にクライアント用の型が生成されるため、フロントエンドとバックエンドの型を完全に統一できます。
通常のAPIレスポンス(数十〜数百件)ではパフォーマンス問題はほぼ発生しません。大量データ(数千件の配列等)を毎回バリデーションする場合は影響が出ることがあります。対策として①信頼済みのデータ(DB直接取得)はバリデーションを省略、②フロントエンドではsafeParseをメモ化、③バンドルサイズが気になる場合はValibotへの移行を検討してください。
できます。z.lazy()を使うことで再帰的なスキーマを定義できます。ただしz.inferでは自動的に型を生成できないため、TypeScriptの型を別途定義してスキーマに型パラメーターとして渡す必要があります。カテゴリのツリー構造やコメントのネスト構造など、再帰データを扱う場合に活用できます。
ZodはTypeScriptの型安全性をランタイムまで延伸する実践的なツールです。APIレスポンス・フォームデータ・環境変数の検証に導入することで、予期せぬランタイムエラーを根本から防ぎ、コードの信頼性を大幅に高められます。まずは既存のfetch関数にsafeParseを追加するところから始めてみましょう。
公式サイト より
今すぐ
無料カウンセリング
を予約!