💡 Tips

TypeScript × Zod バリデーション入門|API・フォームを型安全に守る実践パターン

Zodとは?なぜTypeScriptと相性が良いのか

Zodは、TypeScript向けに設計されたスキーマ宣言・バリデーションライブラリです。

従来のio-tsyupと比べて、Zodが特に優れている点は「スキーマ定義からTypeScriptの型を自動導出できる」ことです。型定義とバリデーションロジックを二重管理する必要がなく、スキーマが唯一の真実(Single Source of Truth)になります。

import { z } from "zod";

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

// ↓ スキーマから型を自動導出。手書き不要!
type User = z.infer<typeof UserSchema>;

この1点だけでも、TypeScriptプロジェクトに導入する価値があります。


インストールと基本設定

npm install zod
# or
pnpm add zod

TypeScript 4.5以上、strict: true 設定を推奨します。Zodはそれ自体に型推論が多用されているため、strictモードで真価を発揮します。


基本的なスキーマの書き方

プリミティブ型

const schema = z.object({
  name: z.string(),
  age: z.number().int().min(0).max(150),
  isActive: z.boolean(),
  createdAt: z.date(),
  role: z.enum(["admin", "user", "guest"]),
});

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

const schema = z.object({
  nickname: z.string().optional(),         // undefined OK
  bio: z.string().nullable(),              // null OK
  lang: z.string().default("ja"),          // 未指定時に "ja"
});

バリデーション後の型

type Payload = z.infer<typeof schema>;
// { nickname?: string; bio: string | null; lang: string }

実践①:APIリクエストのバリデーション

Express / Hono / Cloudflare Workers などのバックエンドで、受け取ったJSONを安全に扱うパターンです。

import { z } from "zod";

// スキーマ定義
const CreatePostSchema = z.object({
  title: z.string().min(1, "タイトルは必須です").max(100),
  body: z.string().min(10, "本文は10文字以上で入力してください"),
  tags: z.array(z.string()).max(5, "タグは最大5件まで"),
  publishedAt: z.string().datetime().optional(),
});

type CreatePostInput = z.infer<typeof CreatePostSchema>;

// Honoでの使用例
app.post("/posts", async (c) => {
  const raw = await c.req.json();

  const result = CreatePostSchema.safeParse(raw);

  if (!result.success) {
    // result.error.flatten() でフィールド別エラーを取得
    return c.json(
      { errors: result.error.flatten().fieldErrors },
      400
    );
  }

  // result.data は CreatePostInput 型として安全に使える
  const post = await createPost(result.data);
  return c.json(post, 201);
});

ポイント:parse vs safeParse

メソッド失敗時の挙動使いどころ
parse()例外をthrow信頼できる内部データ
safeParse(){ success, data/error } を返す外部入力・APIリクエスト

外部入力には必ず safeParse を使い、例外の漏れを防ぎましょう。


実践②:フォームバリデーション(React Hook Form連携)

フロントエンドでは React Hook Form + Zod の組み合わせが定番です。@hookform/resolvers を使います。

npm install react-hook-form @hookform/resolvers
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const SignupSchema = z
  .object({
    email: z.string().email("正しいメールアドレスを入力してください"),
    password: z.string().min(8, "パスワードは8文字以上"),
    confirm: z.string(),
  })
  .refine((data) => data.password === data.confirm, {
    message: "パスワードが一致しません",
    path: ["confirm"], // エラーを表示するフィールドを指定
  });

type SignupInput = z.infer<typeof SignupSchema>;

export function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignupInput>({
    resolver: zodResolver(SignupSchema),
  });

  const onSubmit = (data: SignupInput) => {
    console.log(data); // 型安全なデータ
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="password" {...register("password")} />
      {errors.password && <p>{errors.password.message}</p>}

      <input type="password" {...register("confirm")} />
      {errors.confirm && <p>{errors.confirm.message}</p>}

      <button type="submit">登録</button>
    </form>
  );
}

.refine() を使うことで、フィールドをまたいだ相関バリデーション(パスワード確認など)もスキーマ側に集約できます。


実践③:ネスト・Union・変換(transform)

ネストしたオブジェクト

const AddressSchema = z.object({
  zip: z.string().regex(/^\d{7}$/, "7桁の数字で入力"),
  prefecture: z.string(),
  city: z.string(),
});

const OrderSchema = z.object({
  items: z.array(
    z.object({
      productId: z.string().uuid(),
      quantity: z.number().int().positive(),
    })
  ),
  shippingAddress: AddressSchema, // スキーマを再利用
});

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() }),
]);

discriminatedUnion は判別キー(type)で分岐するため、通常の z.union よりパフォーマンスが良くエラーメッセージも明確です。

transform で入力値を変換

const TrimmedStringSchema = z
  .string()
  .trim()                         // 前後空白を除去
  .transform((val) => val.toLowerCase()); // 小文字化

// parse後の型は string
const result = TrimmedStringSchema.parse("  Hello World  ");
// → "hello world"

エラーハンドリングの実践的な整形

function formatZodErrors(error: z.ZodError) {
  return error.errors.map((e) => ({
    field: e.path.join("."),
    message: e.message,
  }));
}

const result = CreatePostSchema.safeParse(input);
if (!result.success) {
  const errors = formatZodErrors(result.error);
  // [{ field: "title", message: "タイトルは必須です" }, ...]
}

error.flatten() も便利ですが、ネストしたフィールドのパスを正確に取りたい場合は error.errors を直接使う方が柔軟です。


よくある落とし穴と対策

問題原因対策
parse が例外を出し続ける外部入力に parse を使っているsafeParse に変更する
z.date() がAPIで機能しないJSON日付は文字列で届くz.string().datetime()transform(new Date(...))
optional()nullable() の混同意味が違うoptional = undefinedOK、nullable = nullOK
スキーマが巨大になる1ファイルに全スキーマドメインごとにファイルを分割して import

📚 おすすめ書籍

TypeScript実践プログラミング

型安全な設計をより深く学びたい方に

Amazonで見る →

まとめ

Zodを使ったバリデーションの実践パターンをまとめます。

  • スキーマから型を導出z.infer)することで型定義の二重管理をなくす
  • 外部入力には safeParse を使い、例外を制御する
  • フォームは React Hook Form + zodResolver の組み合わせが最も管理しやすい
  • .refine() で相関バリデーション、.transform() でデータ変換をスキーマに集約する
  • discriminatedUnion でUnion型のパフォーマンスと可読性を向上させる

Zodを導入するだけで「型は合っているのにランタイムでクラッシュする」という問題の大半を防げます。まずは既存プロジェクトのAPIエンドポイント1本から試してみてください。