WithCodeMedia-1-pc
previous arrowprevious arrow
next arrownext arrow

WithCodeMedia-1-sp
previous arrowprevious arrow
next arrownext arrow

Zod完全ガイド|TypeScriptのAPIバリデーション・スキーマ全型詳解・refine・transform・環境変数検証・tRPC連携・React Hook Form・Yup比較まで徹底解説

この記事でわかること

  • TypeScriptの型だけでは防げないランタイムエラーとZodが必要な理由
  • プリミティブ・オブジェクト・Union・discriminated unionなど全スキーマ型の詳解
  • refine/superRefineによるカスタムバリデーションとtransform/coerceによるデータ変換
  • 環境変数検証・APIレスポンス検証・React Hook Form連携・Next.js/Honoサーバーサイドバリデーション
  • ZodとYup・Valibotの比較と2つの設計パターン(Single Source of Truth vs 境界のみで使用)

結論から言うと、ZodはTypeScriptの型安全性をランタイムまで延伸する実践的なライブラリです。TypeScriptの型はJavaScriptにコンパイルされる際に消えるため、APIレスポンスの型が実際に正しいかはランタイムでしか検証できません。Zodのスキーマはランタイムで動作し、z.inferでTypeScriptの型も自動生成できるため、型定義とバリデーションの二重管理が不要になります。


目次

なぜZodが必要か|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 }

Zodのインストールと基本的な使い方

>npm install zod
# or
pnpm add zod
yarn add zod

スキーマ定義とz.inferによる型自動生成

>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() vs safeParse() の使い分け

メソッド失敗時の挙動推奨用途
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: [...] }
}

Zodの全スキーマ型リファレンス

プリミティブ型

>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・Discriminated Union・Intersection

>// 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 }

refine / superRefine|カスタムバリデーション

>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 にもできる
})

transform / preprocess / coerce|データ変換

>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"

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

>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と認識
}

フォームバリデーション|React Hook Form連携

>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 (
    
{errors.username && ( )}
{errors.email &&

{errors.email.message}

}
{errors.password &&

{errors.password.message}

}
{errors.confirmPassword &&

{errors.confirmPassword.message}

}
{errors.plan &&

{errors.plan.message}

}
{errors.agreeToTerms &&

{errors.agreeToTerms.message}

}
) }

サーバーサイドバリデーション|Next.js・Hono

Next.js App Router でのリクエストバリデーション

>// 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 + Zodバリデーターミドルウェア

>// 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

ZodをどこでどのようにするかUの2つの設計パターン

>【パターン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の比較

比較項目ZodYupValibot
TypeScript連携◎ z.inferで型自動生成△ 別途型定義が必要◎ 型推論対応
バンドルサイズ△ 約14KB(gzip)△ 約18KB(gzip)◎ 約1KB(tree-shaking)
React Hook Form連携◎ @hookform/resolvers◎ @hookform/resolvers○ @hookform/resolvers
エコシステム◎ 最大(tRPC・drizzle等)○ 豊富△ 発展途上
APIメソッドチェーンメソッドチェーン関数合成スタイル
非同期バリデーション○(parseAsync)◎(ネイティブ対応)○(parseAsync)

よくある質問

zodResolverとinterfaceの型定義は両方必要ですか?

z.inferでスキーマから型を生成すれば、interfaceの別途定義は不要です。type LoginForm = z.infer<typeof LoginSchema>とするだけでTypeScriptの型として機能します。interfaceとZodスキーマの二重管理はミスの原因になるため、パターン1(スキーマが唯一の真実)を採用する場合はz.inferのみを使うのがベストプラクティスです。

optionalとnullableの違いは何ですか?

z.string().optional()string | undefinedの型を生成し、フィールドが存在しなくてもエラーにならない設定です。z.string().nullable()string | nullの型を生成し、フィールドがnullでもエラーにならない設定です。z.string().nullish()string | null | undefinedの型を生成し、どちらも許可します。APIの仕様に合わせて選択してください。

tRPCとZodの組み合わせ方を教えてください。

tRPCはZodとの組み合わせが前提設計になっています。プロシージャの入力スキーマとしてinput(z.object({...}))を定義するだけで、クライアント側とサーバー側の両方で型安全が確保されます。tRPCではZodスキーマから自動的にクライアント用の型が生成されるため、フロントエンドとバックエンドの型を完全に統一できます。

Zodのパフォーマンスは大丈夫ですか?

通常のAPIレスポンス(数十〜数百件)ではパフォーマンス問題はほぼ発生しません。大量データ(数千件の配列等)を毎回バリデーションする場合は影響が出ることがあります。対策として①信頼済みのデータ(DB直接取得)はバリデーションを省略、②フロントエンドではsafeParseをメモ化、③バンドルサイズが気になる場合はValibotへの移行を検討してください。

Zodで再帰的なスキーマ(ツリー構造など)は定義できますか?

できます。z.lazy()を使うことで再帰的なスキーマを定義できます。ただしz.inferでは自動的に型を生成できないため、TypeScriptの型を別途定義してスキーマに型パラメーターとして渡す必要があります。カテゴリのツリー構造やコメントのネスト構造など、再帰データを扱う場合に活用できます。


まとめ

  • Zodの役割:TypeScriptの型が消えるコンパイル後もランタイムでデータを検証。型安全とランタイム安全を両立
  • z.infer:スキーマからTypeScriptの型を自動生成。interfaceとの二重定義が不要で、変更箇所が1か所に集約される
  • safeParse:エラーをスローしない安全な検証メソッド。API境界でのバリデーション・フォームエラー表示に最適
  • refine/superRefine:カスタムバリデーションでパスワード確認などのクロスフィールド検証を実装
  • transform/coerce:バリデーションと同時にデータ変換。フォームデータ(全て文字列)を型変換するのに特に有効
  • 環境変数検証:アプリ起動時に環境変数を検証することで、設定ミスを早期発見できる
  • React Hook Form連携:zodResolverで接続。スキーマのルールがそのままフォームバリデーションになる
  • サーバーサイド:Next.js/Honoでリクエストのクエリパラメーター・ボディを検証してバリデーションエラーを返す
  • 設計パターン:全体をZodで管理(唯一の真実)か境界のみで使うか(関心の分離)はプロジェクト規模で選択

ZodはTypeScriptの型安全性をランタイムまで延伸する実践的なツールです。APIレスポンス・フォームデータ・環境変数の検証に導入することで、予期せぬランタイムエラーを根本から防ぎ、コードの信頼性を大幅に高められます。まずは既存のfetch関数にsafeParseを追加するところから始めてみましょう。


WithCodeを体験できる初級コース公開中!

この記事を書いた人

WithCode(ウィズコード)は「目指すなら稼げる人材」をビジョンに、累計400名以上のフリーランスを輩出してきた超実践型プログラミングスクールです。150社以上の実案件支援を特徴にWeb制作・Webデザインなどの役立つ情報を現場のノウハウに基づいて発信していきます。

– service –WithGroupの運営サービス

  • WithCode
    - ウィズコード -

    スクール

    「未経験」から
    現場で通用する
    スキルを身に付けよう!

    詳細はこちら
  • WithFree
    - ウィズフリ -

    実案件サポート

    制作会社のサポート下で
    実務経験を積んでいこう!

    詳細はこちら
  • WithCareer
    - ウィズキャリ -

    就転職サポート

    大手エージェントのサポート下で
    キャリアアップを目指そう!

    詳細はこちら

公式サイト より
今すぐ
無料カウンセリング
予約!

目次