※本記事には広告(楽天アフィリエイト)リンクが含まれる場合があります。

「フォームのバリデーションと型が二重管理になっている」「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に置き換えるところから始めてみましょう。小さな変更でも型の恩恵をすぐに実感できます。