Zod

シリアライゼーションTypeScriptバリデーションスキーマ型安全ランタイム検証

ライブラリ

Zod

概要

Zodは、TypeScriptファーストのスキーマ宣言とバリデーションライブラリです。静的型推論により、スキーマ定義から自動的にTypeScriptの型を生成し、ランタイムでの型検証とコンパイル時の型安全性を両立します。APIレスポンスの検証、フォームバリデーション、環境変数の型チェックなど、外部データの安全な取り込みに最適です。直感的なAPIと優れたエラーメッセージにより、開発者体験を大幅に向上させます。

詳細

Zodは、TypeScriptの型システムを最大限に活用したスキーマバリデーションライブラリです。単一のスキーマ定義から、TypeScriptの型とランタイムバリデーターの両方を生成することで、型定義の重複を排除します。関数型プログラミングの原則に基づいた設計により、スキーマの合成や変換が容易で、複雑なデータ構造にも対応できます。parse(例外をスロー)とsafeParse(結果オブジェクトを返す)の2つのAPIにより、エラーハンドリングも柔軟に行えます。

主な特徴

  • TypeScript型推論: スキーマから自動的に型を生成
  • 包括的な型サポート: プリミティブ、オブジェクト、配列、ユニオン、交差型など
  • カスタムバリデーション: refineメソッドによる柔軟な検証ロジック
  • エラーハンドリング: 詳細で分かりやすいエラーメッセージ
  • 変換機能: transform、preprocessによるデータ変換
  • 非同期バリデーション: 外部APIを使用した検証も可能

メリット・デメリット

メリット

  • TypeScriptとの完璧な統合(型推論が強力)
  • ゼロ依存で軽量(約8KB gzipped)
  • 直感的で読みやすいAPI設計
  • 詳細なエラー情報とカスタマイズ可能なメッセージ
  • スキーマの合成と再利用が容易
  • アクティブな開発とコミュニティサポート

デメリット

  • ランタイムオーバーヘッドがある
  • 大規模なスキーマでは初期化コストが高い
  • JSON Schemaとの直接的な互換性なし
  • 一部の高度な型(条件付き型など)は表現困難
  • バンドルサイズが気になる場合がある
  • 学習曲線がやや急(高度な機能)

参考ページ

書き方の例

基本的なスキーマ定義と検証

import { z } from 'zod';

// スキーマ定義
const UserSchema = z.object({
  id: z.number().positive(),
  name: z.string().min(1).max(50),
  email: z.string().email(),
  age: z.number().int().min(0).max(150),
  isActive: z.boolean().default(true),
  roles: z.array(z.string()).optional()
});

// TypeScript型の自動生成
type User = z.infer<typeof UserSchema>;

// バリデーション実行
try {
  const user = UserSchema.parse({
    id: 1,
    name: "田中太郎",
    email: "[email protected]",
    age: 30
  });
  console.log("Valid user:", user);
} catch (error) {
  if (error instanceof z.ZodError) {
    console.error("Validation errors:", error.errors);
  }
}

// 安全なパース
const result = UserSchema.safeParse(userData);
if (result.success) {
  console.log("Valid data:", result.data);
} else {
  console.error("Validation failed:", result.error.format());
}

APIレスポンスの検証

import { z } from 'zod';

// APIレスポンススキーマ
const ApiResponseSchema = z.object({
  status: z.literal('success').or(z.literal('error')),
  data: z.object({
    users: z.array(z.object({
      id: z.string().uuid(),
      name: z.string(),
      createdAt: z.string().datetime()
    })),
    total: z.number(),
    page: z.number(),
    pageSize: z.number()
  }).optional(),
  error: z.object({
    code: z.string(),
    message: z.string()
  }).optional()
});

// API呼び出しとバリデーション
async function fetchUsers(page: number) {
  const response = await fetch(`/api/users?page=${page}`);
  const json = await response.json();
  
  // レスポンスを検証
  const validatedData = ApiResponseSchema.parse(json);
  
  if (validatedData.status === 'success' && validatedData.data) {
    return validatedData.data.users;
  } else if (validatedData.error) {
    throw new Error(validatedData.error.message);
  }
}

カスタムバリデーションと変換

import { z } from 'zod';

// カスタムバリデーション
const PasswordSchema = z
  .string()
  .min(8, "パスワードは8文字以上必要です")
  .refine(
    (password) => /[A-Z]/.test(password),
    "大文字を1文字以上含む必要があります"
  )
  .refine(
    (password) => /[0-9]/.test(password),
    "数字を1文字以上含む必要があります"
  )
  .refine(
    (password) => /[!@#$%^&*]/.test(password),
    "特殊文字を1文字以上含む必要があります"
  );

// データ変換
const DateSchema = z
  .string()
  .transform((str) => new Date(str))
  .refine((date) => !isNaN(date.getTime()), {
    message: "有効な日付形式ではありません"
  });

// 前処理を含むスキーマ
const TrimmedString = z.preprocess(
  (val) => typeof val === 'string' ? val.trim() : val,
  z.string()
);

// 使用例
const password = PasswordSchema.parse("MyP@ssw0rd");
const date = DateSchema.parse("2024-01-15");
const trimmed = TrimmedString.parse("  hello world  "); // "hello world"

フォームバリデーション

import { z } from 'zod';

// フォームスキーマ
const RegistrationFormSchema = 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文字以上必要です"),
  
  confirmPassword: z.string(),
  
  age: z
    .number()
    .int("整数を入力してください")
    .min(18, "18歳以上である必要があります"),
  
  terms: z
    .boolean()
    .refine((val) => val === true, "利用規約に同意してください")
}).refine((data) => data.password === data.confirmPassword, {
  message: "パスワードが一致しません",
  path: ["confirmPassword"]
});

// バリデーション関数
function validateForm(formData: unknown) {
  const result = RegistrationFormSchema.safeParse(formData);
  
  if (!result.success) {
    // フィールドごとのエラーを取得
    const fieldErrors = result.error.flatten().fieldErrors;
    return { success: false, errors: fieldErrors };
  }
  
  return { success: true, data: result.data };
}

環境変数の型安全な管理

import { z } from 'zod';

// 環境変数スキーマ
const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  PORT: z.string().transform(Number).pipe(z.number().positive()),
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
  ENABLE_LOGGING: z
    .string()
    .transform((val) => val === 'true')
    .pipe(z.boolean())
    .default('false'),
  MAX_CONNECTIONS: z
    .string()
    .optional()
    .transform((val) => val ? Number(val) : 10)
    .pipe(z.number().positive())
});

// 環境変数の検証
const env = EnvSchema.parse(process.env);

// 型安全なアクセス
export const config = {
  nodeEnv: env.NODE_ENV,
  port: env.PORT,
  databaseUrl: env.DATABASE_URL,
  apiKey: env.API_KEY,
  enableLogging: env.ENABLE_LOGGING,
  maxConnections: env.MAX_CONNECTIONS
} as const;

// 使用例
if (config.nodeEnv === 'production') {
  // 本番環境の設定
}

複雑な型の合成と拡張

import { z } from 'zod';

// 基本スキーマ
const BaseProductSchema = z.object({
  id: z.string().uuid(),
  name: z.string(),
  price: z.number().positive()
});

// 拡張スキーマ
const DetailedProductSchema = BaseProductSchema.extend({
  description: z.string(),
  category: z.enum(['electronics', 'clothing', 'food']),
  tags: z.array(z.string()),
  stock: z.number().int().nonnegative()
});

// ユニオン型
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('sms'),
    phoneNumber: z.string().regex(/^\+[1-9]\d{1,14}$/),
    message: z.string().max(160)
  }),
  z.object({
    type: z.literal('push'),
    deviceToken: z.string(),
    title: z.string(),
    body: z.string(),
    data: z.record(z.string()).optional()
  })
]);

// 条件付きスキーマ
const PaymentSchema = z
  .object({
    method: z.enum(['credit_card', 'bank_transfer', 'paypal']),
    amount: z.number().positive()
  })
  .and(
    z.union([
      z.object({
        method: z.literal('credit_card'),
        cardNumber: z.string().length(16),
        cvv: z.string().length(3)
      }),
      z.object({
        method: z.literal('bank_transfer'),
        accountNumber: z.string(),
        routingNumber: z.string()
      }),
      z.object({
        method: z.literal('paypal'),
        email: z.string().email()
      })
    ])
  );

// 使用例
const notification = NotificationSchema.parse({
  type: 'email',
  to: '[email protected]',
  subject: 'Welcome!',
  body: 'Thank you for signing up.'
});