TypeScriptでZodをマスターする:スキーマバリデーション、型推論、React Hook Form連携

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

ランタイムバリデーションがTypeScriptプロジェクトに欠けている理由

TypeScriptはコンパイル時の型安全性を提供してくれますが、データがネットワーク境界を越えた瞬間、すべての保証は消えてしまいます。APIレスポンス、フォーム送信、クエリパラメータ——これらはどれもランタイムでは型付けされていません。TypeScriptは宣言した型の形をただ信頼するだけです。実際のデータがその宣言と一致しない場合、本番環境でしか表面化しないサイレントバグが発生します。

私自身、これで何度も痛い目に遭ってきました。実際の開発経験から言えば、アプリケーションのエッジ——信頼できない外部世界と型付きTypeScriptコードが出会う場所——でデータをバリデーションするスキルは、ぜひマスターすべき必須スキルのひとつです。そのギャップを埋めてくれるのが、まさにZodです。

ZodはTypeScript向けに設計されたスキーマ宣言・バリデーションライブラリです。スキーマを一度定義するだけで、ランタイムバリデーション(不正なデータを即座に拒否)とTypeScript型推論(型の二重宣言が不要)の2つを同時に得られます。両者は自動的に同期が取れるため、バグのカテゴリ全体をまるごと排除できます。

インストール

Zodは、Node.jsバックエンド、Reactフロントエンド、共有モノレポパッケージなど、あらゆるTypeScriptプロジェクトで動作します。

npm install zod
# または
pnpm add zod
# または
yarn add zod

React Hook Form連携には、Zodリゾルバーも必要です:

npm install react-hook-form @hookform/resolvers zod

依存関係はこれだけです。Zodは外部依存ゼロで、DenoやBunを含むすべてのモダンな環境で動作します。

TypeScriptのバージョンが4.5以上であることを確認してください——ZodはテンプレートリテラルTypeと新しい推論機能に依存しています:

npx tsc --version

設定:はじめてのスキーマを構築する

基本的なスキーマ定義と型推論

コアコンセプトはシンプルです:Zodのプリミティブを使ってスキーマを定義し、z.inferを使ってTypeScript型を取り出します。

import { z } from 'zod';

// スキーマを一度定義する
const UserSchema = z.object({
  id: z.number().int().positive(),
  email: z.string().email(),
  username: z.string().min(3).max(20),
  role: z.enum(['admin', 'editor', 'viewer']),
  createdAt: z.string().datetime().optional(),
});

// TypeScript型を取り出す — 重複なし
type User = z.infer<typeof UserSchema>;

// Userは以下と等価:
// {
//   id: number;
//   email: string;
//   username: string;
//   role: 'admin' | 'editor' | 'viewer';
//   createdAt?: string | undefined;
// }

これが重要なポイントです:スキーマを書けば、TypeScriptが型を自動で書いてくれます。スキーマを更新すると、型も自動的に更新されます。バリデーションロジックにフィールドを追加する際に、インターフェースの更新を忘れることはもうありません。

ランタイムパースとエラーハンドリング

Zodはデータバリデーションに2つのメソッドを提供します:parse()は失敗時に例外をスロー、safeParse()は結果オブジェクトを返します。

const rawData = {
  id: 1,
  email: '[email protected]',
  username: 'john_doe',
  role: 'admin',
};

// オプション1: parse — 無効な場合はZodErrorをスロー
try {
  const user = UserSchema.parse(rawData);
  console.log(user.email); // 完全に型付き
} catch (err) {
  console.error(err); // ZodError(詳細なメッセージ付き)
}

// オプション2: safeParse — { success, data } または { success, error } を返す
const result = UserSchema.safeParse(rawData);
if (result.success) {
  console.log(result.data.role); // 'admin' | 'editor' | 'viewer' として型付き
} else {
  console.error(result.error.flatten());
  // { fieldErrors: { email: ['メールアドレスが無効です'] }, formErrors: [] }
}

safeParse()try/catchのノイズなしに明示的にエラーを処理できるため、アプリケーションコードでは一般的にこちらが好まれます。実行フローを中断することがないのがポイントです。

実践的なパターン:変換、デフォルト値、ネストされたスキーマ

スキーマは自然に組み合わせられます。また、Zodはパース時にデータを変換・成形するトランスフォームをサポートしています:

// ネストされたスキーマ
const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  country: z.string().length(2), // ISO国コード
});

const ProfileSchema = z.object({
  user: UserSchema,
  address: AddressSchema.optional(),
  tags: z.array(z.string()).default([]),
});

// トランスフォーム:日付文字列をDateオブジェクトに変換
const EventSchema = z.object({
  name: z.string(),
  startDate: z.string().transform((val) => new Date(val)),
  attendeeCount: z.coerce.number(), // 文字列 '42' を数値 42 に強制変換
});

type Event = z.infer<typeof EventSchema>;
// startDate はstring ではなく Date(変換後)

// リファインメント:カスタムバリデーションロジック
const PasswordSchema = z
  .object({
    password: z.string().min(8),
    confirm: z.string(),
  })
  .refine((data) => data.password === data.confirm, {
    message: 'パスワードが一致しません',
    path: ['confirm'],
  });

ZodとReact Hook Formを連携する

React Hook Formはフォームの状態を効率的に管理しますが、その組み込みバリデーションはTypeScript型とは別物です。@hookform/resolversパッケージがこのギャップを埋めてくれます——ZodスキーマをリゾルバーとしてReact Hook Formに渡すだけで、バリデーションとTypeScript型推論の両方に使われます。

// components/RegistrationForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const RegistrationSchema = z
  .object({
    email: z.string().email('有効なメールアドレスを入力してください'),
    username: z
      .string()
      .min(3, 'ユーザー名は3文字以上必要です')
      .max(20, 'ユーザー名は20文字以内にしてください')
      .regex(/^[a-z0-9_]+$/, '小文字、数字、アンダースコアのみ使用できます'),
    password: z.string().min(8, 'パスワードは8文字以上必要です'),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: 'パスワードが一致しません',
    path: ['confirmPassword'],
  });

type RegistrationFormData = z.infer<typeof RegistrationSchema>;

export function RegistrationForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<RegistrationFormData>({
    resolver: zodResolver(RegistrationSchema),
  });

  const onSubmit = async (data: RegistrationFormData) => {
    // data は完全に型付きで、すでにバリデーション済み
    await fetch('/api/register', {
      method: 'POST',
      body: JSON.stringify(data),
    });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input {...register('email')} placeholder="メールアドレス" />
        {errors.email && <p>{errors.email.message}</p>}
      </div>

      <div>
        <input {...register('username')} placeholder="ユーザー名" />
        {errors.username && <p>{errors.username.message}</p>}
      </div>

      <div>
        <input type="password" {...register('password')} placeholder="パスワード" />
        {errors.password && <p>{errors.password.message}</p>}
      </div>

      <div>
        <input
          type="password"
          {...register('confirmPassword')}
          placeholder="パスワード確認"
        />
        {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'アカウント作成中...' : '登録'}
      </button>
    </form>
  );
}

この設定の素晴らしい点は:同じRegistrationSchemaAPIルートにインポートして、受信リクエストボディのバリデーションにも使えることです。一つのスキーマで、クライアントとサーバー両方でバリデーションできます。

フロントエンドとバックエンド間でスキーマを共有する

ここでフルスタックの利点が明確になります。共有スキーマファイルを作成してみましょう:

// shared/schemas.ts
import { z } from 'zod';

export const RegistrationSchema = z
  .object({
    email: z.string().email(),
    username: z.string().min(3).max(20),
    password: z.string().min(8),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: 'パスワードが一致しません',
    path: ['confirmPassword'],
  });

export type RegistrationFormData = z.infer<typeof RegistrationSchema>;
// pages/api/register.ts (Next.js API route)
import { RegistrationSchema } from '@/shared/schemas';

export async function POST(request: Request) {
  const body = await request.json();
  const result = RegistrationSchema.safeParse(body);

  if (!result.success) {
    return Response.json(
      { errors: result.error.flatten().fieldErrors },
      { status: 400 }
    );
  }

  // result.data はここで完全に型付き
  const { email, username, password } = result.data;
  // ... ユーザーを作成
}

検証とモニタリング:スキーマが正しく機能するかテストする

スキーマを直接ユニットテストする

スキーマは純粋な関数です——モックなしで簡単にユニットテストできます:

// schemas.test.ts
import { RegistrationSchema } from './shared/schemas';

describe('RegistrationSchema', () => {
  it('有効な登録データを受け付ける', () => {
    const result = RegistrationSchema.safeParse({
      email: '[email protected]',
      username: 'john_doe',
      password: 'secure123',
      confirmPassword: 'secure123',
    });
    expect(result.success).toBe(true);
  });

  it('パスワードが一致しない場合は拒否する', () => {
    const result = RegistrationSchema.safeParse({
      email: '[email protected]',
      username: 'john_doe',
      password: 'secure123',
      confirmPassword: 'different456',
    });
    expect(result.success).toBe(false);
    if (!result.success) {
      expect(result.error.flatten().fieldErrors.confirmPassword).toBeDefined();
    }
  });

  it('無効なメールフォーマットを拒否する', () => {
    const result = RegistrationSchema.safeParse({
      email: 'not-an-email',
      username: 'john_doe',
      password: 'secure123',
      confirmPassword: 'secure123',
    });
    expect(result.success).toBe(false);
  });
});

本番環境でのバリデーションエラーを監視する

本番環境でバリデーションが失敗した場合、その可視性が必要です。APIバリデーションをログ出力でラップしましょう:

const result = RegistrationSchema.safeParse(body);

if (!result.success) {
  const errors = result.error.flatten();
  // モニタリングサービスにログを送る(Sentry、Datadogなど)
  console.warn('[バリデーション失敗]', {
    endpoint: '/api/register',
    fieldErrors: errors.fieldErrors,
    formErrors: errors.formErrors,
  });

  return Response.json({ errors: errors.fieldErrors }, { status: 400 });
}

特定フィールドでバリデーション失敗が頻発する場合、フロントエンドのバグ、間違ったデータフォーマットを送信するモバイルクライアント、またはドキュメントを読んでいないAPIコンシューマーの存在を示していることが多いです。ユーザーから苦情が来てからログを追いかけるのではなく、早い段階でこれらを表面化させましょう。

よくある落とし穴

  • OptionalとNullablez.string().optional()undefinedを許可し、z.string().nullable()nullを許可します。両者は異なります。両方許可するにはz.string().nullish()を使用してください。
  • 未知のキー:デフォルトでは、Zodはパース時に未知のキーを削除します。保持するには.passthrough()を、例外をスローするには.strict()を呼び出してください。
  • 非同期リファインメント:バリデーションがデータベースにアクセスする必要がある場合(例:メールアドレスが既に使われているか確認する場合)は、.refineAsync()parseAsync()を使用してください。
  • エラーメッセージ:カスタムメッセージはバリデーター呼び出しの中に入れます:z.string().min(3, '短すぎます') — 別の設定オブジェクトではありません。
Share: