Zodで型安全バリデーション|TypeScriptのスキーマ検証ライブラリ入門

kento_morota 28分で読めます

TypeScriptの型システムはコンパイル時に型の整合性を検証してくれますが、実行時のデータ(APIレスポンス、フォーム入力、環境変数など)までは保証してくれません。Zodは、スキーマ定義から型推論と実行時バリデーションの両方を提供する、TypeScriptファーストのライブラリです。

本記事では、Zodの基本から実務で使える応用パターンまで、コード例とともに詳しく解説します。

Zodとは何か・なぜ必要なのか

Zodは、TypeScript向けに設計されたスキーマ宣言・バリデーションライブラリです。スキーマを一度定義するだけで、TypeScriptの型と実行時のバリデーションロジックの両方が手に入ります。

TypeScriptの型だけでは不十分な理由

TypeScriptの型はコンパイル時にのみ存在し、実行時にはJavaScriptとして動作します。つまり、外部から入ってくるデータの安全性は保証されません。

// TypeScriptの型定義
interface User {
  name: string;
  email: string;
  age: number;
}

// APIレスポンスを「信頼して」型アサーション
const data = await fetch("/api/user").then((r) => r.json()) as User;

// もしAPIが { name: 123, age: "hello" } を返したら?
// TypeScriptはエラーを出さないが、実行時にバグが発生する
console.log(data.name.toUpperCase()); // ランタイムエラーの可能性

Zodを使えば、実行時にデータの構造と型を検証し、安全な値だけを通すことができます。

Zodの特徴と他ライブラリとの比較

Zodの主な特徴は以下のとおりです。

TypeScriptファースト:スキーマから自動的にTypeScript型を推論できるため、型定義とバリデーションの二重管理が不要です。

ゼロ依存:外部ライブラリへの依存がなく、バンドルサイズが小さいです。

イミュータブル:メソッドチェーンで新しいスキーマを返すため、元のスキーマに影響しません。

充実したエラー情報:どのフィールドがどのような理由で不正かを詳細に報告してくれます。

類似ライブラリとしてYup、io-ts、Valibotなどがありますが、Zodは型推論の精度とAPIの直感性で高い評価を得ています。

Zodのセットアップと基本的な使い方

インストール

# npm
npm install zod

# pnpm
pnpm add zod

# yarn
yarn add zod

TypeScript 4.5以上が推奨されます。tsconfig.jsonstrict: trueを有効にしてください。

基本的なスキーマ定義

import { z } from "zod";

// プリミティブ型
const nameSchema = z.string();
const ageSchema = z.number();
const isActiveSchema = z.boolean();

// バリデーション実行
const name = nameSchema.parse("田中太郎"); // "田中太郎"
// nameSchema.parse(123); // ZodError: Expected string, received number

// 安全なパース(例外を投げない)
const result = nameSchema.safeParse("田中太郎");
if (result.success) {
  console.log(result.data); // "田中太郎"
} else {
  console.error(result.error.issues);
}

オブジェクトスキーマと型推論

const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  role: z.enum(["admin", "editor", "viewer"]),
});

// スキーマからTypeScript型を推論
type User = z.infer<typeof UserSchema>;
// {
//   name: string;
//   email: string;
//   age: number;
//   role: "admin" | "editor" | "viewer";
// }

// バリデーション
const user = UserSchema.parse({
  name: "田中太郎",
  email: "tanaka@example.com",
  age: 30,
  role: "admin",
});
// user は User 型として推論される

このようにz.inferを使えば、スキーマ定義から自動的にTypeScript型が得られるため、型とバリデーションを二重に定義する必要がありません。

バリデーションルールの詳細

Zodは豊富なバリデーションメソッドを提供しています。

文字列のバリデーション

const stringSchemas = {
  // 長さ制限
  username: z.string().min(3, "3文字以上で入力してください").max(20, "20文字以内で入力してください"),

  // 正規表現
  phone: z.string().regex(/^0\d{1,4}-\d{1,4}-\d{4}$/, "電話番号の形式が不正です"),

  // 組み込みバリデーション
  email: z.string().email("メールアドレスの形式が不正です"),
  url: z.string().url("URLの形式が不正です"),
  uuid: z.string().uuid("UUIDの形式が不正です"),

  // トリム・変換
  trimmed: z.string().trim(),
  lowered: z.string().toLowerCase(),

  // 空文字を禁止
  nonEmpty: z.string().min(1, "必須項目です"),

  // 日付文字列
  dateString: z.string().date("日付の形式が不正です"), // YYYY-MM-DD
  dateTimeString: z.string().datetime("日時の形式が不正です"),
};

数値のバリデーション

const numberSchemas = {
  // 範囲
  age: z.number().int().min(0).max(150),

  // 正の数・負の数
  price: z.number().positive("価格は正の数で入力してください"),
  temperature: z.number().min(-50).max(60),

  // 有限の数
  finiteNumber: z.number().finite(),

  // 整数
  quantity: z.number().int("整数で入力してください").nonnegative(),

  // 文字列から数値への変換
  numericString: z.coerce.number(), // "123" → 123
};

配列・タプル・列挙型

// 配列
const tagsSchema = z.array(z.string()).min(1, "1つ以上のタグが必要です").max(10);

// タプル
const coordinateSchema = z.tuple([z.number(), z.number()]);

// 列挙型
const statusSchema = z.enum(["active", "inactive", "pending"]);
type Status = z.infer<typeof statusSchema>; // "active" | "inactive" | "pending"

// ネイティブEnum
enum Direction {
  Up = "UP",
  Down = "DOWN",
  Left = "LEFT",
  Right = "RIGHT",
}
const directionSchema = z.nativeEnum(Direction);

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

const ProfileSchema = z.object({
  name: z.string(),
  bio: z.string().optional(), // string | undefined
  website: z.string().url().nullable(), // string | null
  theme: z.string().default("light"), // デフォルト値付き
  notifications: z.boolean().default(true),
});

type Profile = z.infer<typeof ProfileSchema>;
// {
//   name: string;
//   bio?: string | undefined;
//   website: string | null;
//   theme: string;
//   notifications: boolean;
// }

実務で使えるスキーマパターン

APIリクエスト・レスポンスのバリデーション

// APIレスポンススキーマ
const ApiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
  z.object({
    success: z.boolean(),
    data: dataSchema,
    message: z.string().optional(),
  });

const UserListResponseSchema = ApiResponseSchema(
  z.array(UserSchema)
);

// APIリクエストのバリデーション
const CreateUserRequestSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).optional(),
  role: z.enum(["admin", "editor", "viewer"]).default("viewer"),
});

type CreateUserRequest = z.infer<typeof CreateUserRequestSchema>;

// Expressミドルウェアでの使用例
import { Request, Response, NextFunction } from "express";

function validateBody<T extends z.ZodTypeAny>(schema: T) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      return res.status(400).json({
        success: false,
        errors: result.error.issues.map((issue) => ({
          field: issue.path.join("."),
          message: issue.message,
        })),
      });
    }
    req.body = result.data;
    next();
  };
}

// ルート定義
app.post("/api/users", validateBody(CreateUserRequestSchema), (req, res) => {
  const userData: CreateUserRequest = req.body; // 型安全
  // ...
});

環境変数のバリデーション

const EnvSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  PORT: z.coerce.number().int().positive().default(3000),
  DATABASE_URL: z.string().url(),
  API_SECRET: z.string().min(32, "APIシークレットは32文字以上必要です"),
  REDIS_URL: z.string().url().optional(),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});

type Env = z.infer<typeof EnvSchema>;

function loadEnv(): Env {
  const result = EnvSchema.safeParse(process.env);
  if (!result.success) {
    console.error("環境変数の検証に失敗しました:");
    for (const issue of result.error.issues) {
      console.error(`  ${issue.path.join(".")}: ${issue.message}`);
    }
    process.exit(1);
  }
  return result.data;
}

export const env = loadEnv();
// env.PORT は number 型として使える
// env.DATABASE_URL は string 型として使える

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

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

const ContactFormSchema = z.object({
  name: z.string().min(1, "名前を入力してください"),
  email: z.string().email("メールアドレスの形式が不正です"),
  category: z.enum(["inquiry", "support", "other"], {
    errorMap: () => ({ message: "カテゴリを選択してください" }),
  }),
  message: z
    .string()
    .min(10, "10文字以上で入力してください")
    .max(1000, "1000文字以内で入力してください"),
  agreeToTerms: z.literal(true, {
    errorMap: () => ({ message: "利用規約に同意してください" }),
  }),
});

type ContactForm = z.infer<typeof ContactFormSchema>;

function ContactFormComponent() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<ContactForm>({
    resolver: zodResolver(ContactFormSchema),
  });

  const onSubmit = (data: ContactForm) => {
    // data は型安全かつバリデーション済み
    console.log(data);
  };

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

      <input {...register("email")} />
      {errors.email && <span>{errors.email.message}</span>}

      <textarea {...register("message")} />
      {errors.message && <span>{errors.message.message}</span>}

      <button type="submit">送信</button>
    </form>
  );
}

高度なスキーマ操作

スキーマの合成と拡張

// ベーススキーマ
const BaseEntitySchema = z.object({
  id: z.number().int().positive(),
  createdAt: z.coerce.date(),
  updatedAt: z.coerce.date(),
});

// 拡張(mergeを使用)
const ArticleSchema = BaseEntitySchema.merge(
  z.object({
    title: z.string().min(1).max(200),
    content: z.string().min(1),
    author: z.string(),
    tags: z.array(z.string()),
    status: z.enum(["draft", "published", "archived"]),
  })
);

type Article = z.infer<typeof ArticleSchema>;

// pick / omit でスキーマを加工
const ArticleCreateSchema = ArticleSchema.omit({
  id: true,
  createdAt: true,
  updatedAt: true,
});

const ArticleSummarySchema = ArticleSchema.pick({
  id: true,
  title: true,
  author: true,
  status: true,
});

// partial で全フィールドをオプショナルに
const ArticleUpdateSchema = ArticleSchema.omit({
  id: true,
  createdAt: true,
}).partial();

discriminatedUnion(判別共用体)

const NotificationSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("email"),
    to: z.string().email(),
    subject: z.string(),
    body: z.string(),
  }),
  z.object({
    type: z.literal("slack"),
    channel: z.string(),
    message: z.string(),
    mention: z.array(z.string()).optional(),
  }),
  z.object({
    type: z.literal("webhook"),
    url: z.string().url(),
    payload: z.record(z.unknown()),
  }),
]);

type Notification = z.infer<typeof NotificationSchema>;

// 型安全に分岐処理できる
function sendNotification(notification: Notification) {
  switch (notification.type) {
    case "email":
      // notification.to, notification.subject が使える
      break;
    case "slack":
      // notification.channel, notification.message が使える
      break;
    case "webhook":
      // notification.url, notification.payload が使える
      break;
  }
}

transform・refine・preprocessによるカスタムバリデーション

// transform: データの変換
const DateStringSchema = z.string().transform((val) => new Date(val));

// refine: カスタムバリデーション
const PasswordSchema = z
  .string()
  .min(8, "8文字以上で入力してください")
  .refine((val) => /[A-Z]/.test(val), "大文字を1文字以上含めてください")
  .refine((val) => /[a-z]/.test(val), "小文字を1文字以上含めてください")
  .refine((val) => /[0-9]/.test(val), "数字を1文字以上含めてください");

// superRefine: 複数フィールド間のバリデーション
const RegisterFormSchema = z
  .object({
    password: z.string().min(8),
    confirmPassword: z.string(),
    startDate: z.coerce.date(),
    endDate: z.coerce.date(),
  })
  .superRefine((data, ctx) => {
    if (data.password !== data.confirmPassword) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "パスワードが一致しません",
        path: ["confirmPassword"],
      });
    }
    if (data.endDate <= data.startDate) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "終了日は開始日より後にしてください",
        path: ["endDate"],
      });
    }
  });

// preprocess: パース前にデータを前処理
const NumericStringSchema = z.preprocess(
  (val) => (typeof val === "string" ? Number(val) : val),
  z.number().positive()
);

エラーハンドリングとカスタムエラーメッセージ

エラー情報の構造

const result = UserSchema.safeParse({
  name: "",
  email: "invalid-email",
  age: -5,
  role: "unknown",
});

if (!result.success) {
  // flatten でフィールドごとのエラーに整理
  const fieldErrors = result.error.flatten().fieldErrors;
  // {
  //   name: ["String must contain at least 1 character(s)"],
  //   email: ["Invalid email"],
  //   age: ["Number must be greater than or equal to 0"],
  //   role: ["Invalid enum value..."]
  // }

  // format でネスト構造を維持
  const formatted = result.error.format();
  // formatted.email?._errors → ["Invalid email"]
}

日本語エラーメッセージの設定

// グローバルなエラーマップ
const customErrorMap: z.ZodErrorMap = (issue, ctx) => {
  switch (issue.code) {
    case z.ZodIssueCode.invalid_type:
      if (issue.expected === "string") return { message: "文字列を入力してください" };
      if (issue.expected === "number") return { message: "数値を入力してください" };
      break;
    case z.ZodIssueCode.too_small:
      if (issue.type === "string") return { message: `${issue.minimum}文字以上で入力してください` };
      if (issue.type === "number") return { message: `${issue.minimum}以上の値を入力してください` };
      break;
    case z.ZodIssueCode.too_big:
      if (issue.type === "string") return { message: `${issue.maximum}文字以内で入力してください` };
      break;
    case z.ZodIssueCode.invalid_string:
      if (issue.validation === "email") return { message: "メールアドレスの形式が不正です" };
      if (issue.validation === "url") return { message: "URLの形式が不正です" };
      break;
  }
  return { message: ctx.defaultError };
};

z.setErrorMap(customErrorMap);

// フィールドごとの個別メッセージ
const ItemSchema = z.object({
  name: z.string({ required_error: "商品名は必須です" }).min(1, "商品名を入力してください"),
  price: z.number({ required_error: "価格は必須です" }).positive("価格は正の数で入力してください"),
});

tRPC・Next.jsとの連携

tRPCでのZod活用

import { initTRPC } from "@trpc/server";

const t = initTRPC.create();

const appRouter = t.router({
  getUser: t.procedure
    .input(z.object({ id: z.number().int().positive() }))
    .query(async ({ input }) => {
      // input.id は number 型として型安全
      return await db.user.findUnique({ where: { id: input.id } });
    }),

  createUser: t.procedure
    .input(CreateUserRequestSchema) // 再利用
    .mutation(async ({ input }) => {
      // input は CreateUserRequest 型
      return await db.user.create({ data: input });
    }),

  searchUsers: t.procedure
    .input(
      z.object({
        query: z.string().optional(),
        role: z.enum(["admin", "editor", "viewer"]).optional(),
        page: z.number().int().positive().default(1),
        perPage: z.number().int().positive().max(100).default(20),
      })
    )
    .query(async ({ input }) => {
      // ページネーション付き検索
      return await db.user.findMany({
        where: {
          name: input.query ? { contains: input.query } : undefined,
          role: input.role,
        },
        skip: (input.page - 1) * input.perPage,
        take: input.perPage,
      });
    }),
});

Next.js Server Actionsでの活用

"use server";

import { z } from "zod";

const ContactSchema = z.object({
  name: z.string().min(1, "名前を入力してください"),
  email: z.string().email("メールアドレスの形式が不正です"),
  message: z.string().min(10, "10文字以上で入力してください"),
});

export async function submitContact(formData: FormData) {
  const rawData = {
    name: formData.get("name"),
    email: formData.get("email"),
    message: formData.get("message"),
  };

  const result = ContactSchema.safeParse(rawData);

  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
    };
  }

  // result.data は型安全
  await sendEmail(result.data);

  return { success: true };
}

まとめ

Zodは、TypeScriptプロジェクトにおけるバリデーションの課題を根本から解決するライブラリです。

基本的な使い方:z.object、z.string、z.numberなどでスキーマを定義し、parseまたはsafeParseでバリデーションを実行します。z.inferでTypeScript型を自動推論でき、型の二重定義が不要です。

実務での活用:APIリクエスト・レスポンスの検証、環境変数のバリデーション、React Hook Formとの連携、tRPCやNext.js Server Actionsでの型安全なデータ受け取りなど、幅広い場面で活用できます。

高度な機能:discriminatedUnionによる判別共用体、transform・refineによるカスタム変換・検証、スキーマのmerge・pick・omitによる合成も可能です。

「型で守れる範囲は型で、実行時に守る範囲はZodで」という方針を徹底することで、予期しないデータによるバグを大幅に削減できます。まずは環境変数やAPIリクエストのバリデーションから導入してみてください。

#TypeScript#Zod#バリデーション
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

起業準備に役立つ情報、もっとありますよ。

まずは話だけ聞いてもらう