T-CREATOR

Zod で“境界”を守る設計思想:IO バリデーションと型推論の二刀流

Zod で“境界”を守る設計思想:IO バリデーションと型推論の二刀流

現代の Web アプリケーション開発では、外部から流入するデータの安全性を確保することが極めて重要です。API のレスポンス、フォーム入力、環境変数など、システムの「境界」を越えてくるデータは信頼できません。そこで注目されているのが、TypeScript のバリデーションライブラリ「Zod」です。

Zod は単なるバリデーションツールではなく、「境界を守る」という明確な設計思想を持っています。実行時バリデーションと型推論を同時に実現することで、外部データの入り口で確実にガードし、内部では型安全なコードを書けるようにします。本記事では、Zod の二刀流アプローチがどのように境界防衛を実現するのか、その設計思想を深掘りしていきましょう。

背景

アプリケーションの「境界」とは

Web アプリケーションには必ず「外部」と「内部」の境界が存在します。この境界を越えてデータが流入する箇所こそ、最もリスクの高いポイントなのです。

下図は、典型的な Web アプリケーションにおけるデータの境界を示しています。

mermaidflowchart TB
    subgraph external["外部(信頼できない領域)"]
        api["外部 API"]
        form["ユーザー入力"]
        env["環境変数"]
    end

    subgraph boundary["境界(バリデーション層)"]
        validation["バリデーション<br/>チェックポイント"]
    end

    subgraph internal["内部(信頼できる領域)"]
        logic["ビジネスロジック"]
        db["データベース"]
    end

    api -->|未検証データ| validation
    form -->|未検証データ| validation
    env -->|未検証データ| validation
    validation -->|検証済みデータ| logic
    logic --> db

図で理解できる要点:

  • 外部からの全てのデータは「境界」で検証される必要がある
  • 検証を通過したデータのみが内部ロジックに流入する
  • 境界防衛が破られると、システム全体が危険にさらされる

TypeScript だけでは守れない境界

TypeScript は優れた型システムを持っていますが、あくまでコンパイル時のチェックに留まります。実行時に外部から流入するデータの形状を保証することはできません。

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

上記のような型定義があっても、実行時には以下のようなコードが通ってしまいます。

typescript// API レスポンスを User 型としてキャスト
const response = await fetch('/api/user');
const user = (await response.json()) as User;

// しかし実際のレスポンスが以下だったら?
// { id: "abc", name: 123, invalid: true }
// TypeScript は実行時エラーを防げない

このように、TypeScript の型アサーション(as)は単なる「型の主張」であり、実際のデータ構造を検証しません。

実行時バリデーションの必要性

境界を守るには、実行時に動作するバリデーションが不可欠です。しかし、従来のアプローチには課題がありました。

#アプローチ特徴課題
1手動チェックif 文で各プロパティを検証コード量が多く、型定義と二重管理
2JSON Schema標準的な検証フォーマットTypeScript 型との同期が困難
3class-validatorデコレータベースの検証クラス定義が必須、型推論が弱い

これらの手法では、バリデーションロジックと TypeScript の型定義を別々に管理する必要があり、メンテナンスコストが高くなります。そこで登場したのが Zod です。

課題

二重管理の問題

従来の開発では、実行時バリデーションと型定義を別々に記述する必要がありました。この二重管理こそが、境界防衛を困難にする最大の課題です。

以下は、従来の JSON Schema と TypeScript を併用した例です。

typescript// JSON Schema によるバリデーション定義
const userSchema = {
  type: 'object',
  properties: {
    id: { type: 'number' },
    name: { type: 'string' },
    email: { type: 'string', format: 'email' },
  },
  required: ['id', 'name', 'email'],
};
typescript// TypeScript の型定義(別途必要)
interface User {
  id: number;
  name: string;
  email: string;
}

このアプローチでは、仕様変更時に両方を更新する必要があり、同期ミスが発生しやすくなります。

型安全性の欠如

バリデーション後のデータに対して、TypeScript が型を推論できないという問題もあります。

typescript// バリデーションライブラリでチェック
const isValid = validate(userSchema, data);

if (isValid) {
  // data は依然として any 型のまま
  // TypeScript は data.name が string と認識できない
  console.log(data.name.toUpperCase());
}

バリデーションを通過したにもかかわらず、TypeScript の型システムがその情報を活用できないのです。これでは境界を越えた後も、型の恩恵を受けられません。

エラーハンドリングの複雑さ

どのフィールドがどのような理由で検証失敗したのか、詳細なエラー情報を取得することも課題でした。

typescript// 単純な true/false だけでは不十分
if (!isValid) {
  // どのフィールドが問題なのか?
  // どのような値が期待されていたのか?
  // エラーメッセージをどう構築するのか?
  throw new Error('Validation failed');
}

境界でのエラーを適切にハンドリングできなければ、デバッグが困難になり、ユーザーへの適切なフィードバックも提供できません。

解決策

Zod のスキーマファースト設計

Zod は「スキーマを定義すれば、型も自動的についてくる」という革新的なアプローチを採用しています。これが境界防衛を大幅に簡素化します。

typescriptimport { z } from 'zod';

// スキーマ定義(実行時バリデーション)
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

このスキーマ定義から、TypeScript の型を自動生成できます。

typescript// スキーマから型を推論
type User = z.infer<typeof UserSchema>;

// 上記は以下の型と等価
// type User = {
//   id: number;
//   name: string;
//   email: string;
// }

一つのスキーマ定義から、実行時バリデーションと型定義の両方を得られるのです。これにより二重管理の問題が解消されます。

バリデーションと型推論の統合

Zod の parse メソッドは、バリデーションと型の絞り込みを同時に行います。

typescript// API からデータを取得
const response = await fetch('/api/user');
const data = await response.json();
typescript// Zod でバリデーション + 型推論
try {
  const user = UserSchema.parse(data);

  // この時点で user は User 型として扱える
  // TypeScript が user.name を string と認識
  console.log(user.name.toUpperCase()); // 型安全
} catch (error) {
  // バリデーションエラーの処理
  console.error('Invalid user data');
}

parse を通過した時点で、TypeScript は userUser 型であることを認識します。これが「型推論の二刀流」の核心です。

下図は、Zod による境界防衛のフローを示しています。

mermaidsequenceDiagram
    participant External as 外部データ<br/>(unknown)
    participant Zod as Zod Schema<br/>.parse()
    participant Internal as 内部コード<br/>(型安全)

    External->>Zod: 未検証データを渡す

    alt バリデーション成功
        Zod->>Zod: 実行時チェック通過
        Zod->>Internal: 型付きデータを返す
        Note over Internal: TypeScript が型を認識<br/>安全にプロパティアクセス
    else バリデーション失敗
        Zod->>Zod: エラーを検出
        Zod-->>External: ZodError を throw
        Note over External: 詳細なエラー情報<br/>どのフィールドが問題か明確
    end

図で理解できる要点:

  • parse メソッドが境界のゲートとして機能
  • 成功時は型推論により内部で型安全性を確保
  • 失敗時は詳細なエラー情報を提供

豊富なバリデーション機能

Zod は境界で必要となる様々なバリデーションをサポートしています。

typescript// 文字列のバリデーション
const EmailSchema = z
  .string()
  .email('有効なメールアドレスを入力してください')
  .min(5, 'メールアドレスは5文字以上必要です');
typescript// 数値の範囲チェック
const AgeSchema = z
  .number()
  .int('年齢は整数で入力してください')
  .positive('年齢は正の数である必要があります')
  .max(120, '年齢は120歳以下である必要があります');
typescript// カスタムバリデーション
const PasswordSchema = z
  .string()
  .min(8, 'パスワードは8文字以上必要です')
  .refine(
    (val) => /[A-Z]/.test(val),
    'パスワードには大文字を含める必要があります'
  )
  .refine(
    (val) => /[0-9]/.test(val),
    'パスワードには数字を含める必要があります'
  );

これらのバリデーションルールも、すべて TypeScript の型システムと統合されています。

詳細なエラー情報

Zod は境界でのエラーを詳細に報告します。

typescriptconst UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  age: z.number().positive(),
});
typescript// 不正なデータをパース
try {
  UserSchema.parse({
    id: 'abc', // 数値ではない
    name: 123, // 文字列ではない
    email: 'invalid', // メール形式ではない
    age: -5, // 正の数ではない
  });
} catch (error) {
  if (error instanceof z.ZodError) {
    // エラーの詳細を取得
    console.log(error.issues);
    // [
    //   { path: ['id'], message: 'Expected number, received string' },
    //   { path: ['name'], message: 'Expected string, received number' },
    //   { path: ['email'], message: 'Invalid email' },
    //   { path: ['age'], message: 'Number must be greater than 0' }
    // ]
  }
}

各フィールドごとにエラーの内容が明確になり、ユーザーへのフィードバックやデバッグが容易になります。

具体例

API レスポンスの境界防衛

外部 API からのレスポンスは、最も重要な境界の一つです。Zod を使った実装例を見ていきましょう。

まず、期待する API レスポンスのスキーマを定義します。

typescriptimport { z } from 'zod';

// API レスポンススキーマ
const ApiUserSchema = z.object({
  id: z.number(),
  username: z.string(),
  email: z.string().email(),
  profile: z.object({
    age: z.number().optional(),
    bio: z.string().optional(),
  }),
  createdAt: z.string().datetime(),
});
typescript// スキーマから型を生成
type ApiUser = z.infer<typeof ApiUserSchema>;

次に、API クライアント関数を実装します。

typescript// API クライアント(境界防衛機能付き)
async function fetchUser(userId: number): Promise<ApiUser> {
  const response = await fetch(`/api/users/${userId}`);

  if (!response.ok) {
    throw new Error(
      `HTTP error! status: ${response.status}`
    );
  }

  const data = await response.json();

  // 境界でバリデーション
  try {
    return ApiUserSchema.parse(data);
  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error(
        'API レスポンスが期待する形式ではありません:',
        error.issues
      );
    }
    throw error;
  }
}

使用例を示します。

typescript// 使用側のコード
async function displayUserProfile() {
  try {
    // 型安全にデータを取得
    const user = await fetchUser(123);

    // TypeScript が user の型を完全に理解している
    console.log(`ユーザー名: ${user.username}`);
    console.log(`メール: ${user.email}`);

    // オプショナルなプロパティも型安全にアクセス
    if (user.profile.age) {
      console.log(`年齢: ${user.profile.age}歳`);
    }
  } catch (error) {
    console.error('ユーザー情報の取得に失敗しました');
  }
}

このパターンにより、API の境界で確実にバリデーションが実行され、内部コードでは型安全性が保証されます。

フォーム入力の境界防衛

ユーザーからのフォーム入力も重要な境界です。Next.js の Server Actions と組み合わせた例を見てみましょう。

typescript'use server';

import { z } from 'zod';

// フォームスキーマ定義
const ContactFormSchema = z.object({
  name: z
    .string()
    .min(2, '名前は2文字以上で入力してください')
    .max(50, '名前は50文字以内で入力してください'),
  email: z
    .string()
    .email('有効なメールアドレスを入力してください'),
  subject: z
    .string()
    .min(5, '件名は5文字以上で入力してください')
    .max(100, '件名は100文字以内で入力してください'),
  message: z
    .string()
    .min(10, 'メッセージは10文字以上で入力してください')
    .max(
      1000,
      'メッセージは1000文字以内で入力してください'
    ),
});
typescript// 型を推論
type ContactFormData = z.infer<typeof ContactFormSchema>;

// Server Action
export async function submitContactForm(formData: FormData) {
  // FormData を通常のオブジェクトに変換
  const rawData = {
    name: formData.get('name'),
    email: formData.get('email'),
    subject: formData.get('subject'),
    message: formData.get('message')
  };
typescript  // 境界でバリデーション
  const result = ContactFormSchema.safeParse(rawData);

  if (!result.success) {
    // エラーレスポンスを返す
    return {
      success: false,
      errors: result.error.flatten().fieldErrors
    };
  }

  // この時点で result.data は ContactFormData 型
  const validData = result.data;

  // 型安全にビジネスロジックを実行
  await saveContactToDatabase(validData);
  await sendNotificationEmail(validData);

  return { success: true };
}

クライアント側のコンポーネントは以下のようになります。

typescript'use client';

import { submitContactForm } from './actions';

export function ContactForm() {
  async function handleSubmit(formData: FormData) {
    const result = await submitContactForm(formData);

    if (!result.success) {
      // フィールドごとのエラーを表示
      console.error(result.errors);
    } else {
      alert('送信が完了しました');
    }
  }

  return (
    <form action={handleSubmit}>
      {/* フォームフィールド */}
    </form>
  );
}

環境変数の境界防衛

環境変数も外部からの入力として扱い、境界で検証すべきです。

typescriptimport { z } from 'zod';

// 環境変数スキーマ
const EnvSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(32),
  PORT: z
    .string()
    .transform(Number)
    .pipe(z.number().positive()),
  ENABLE_ANALYTICS: z
    .string()
    .transform((val) => val === 'true')
    .pipe(z.boolean()),
});
typescript// 環境変数の型を推論
type Env = z.infer<typeof EnvSchema>;

// 環境変数をバリデーション
function getValidatedEnv(): Env {
  try {
    return EnvSchema.parse(process.env);
  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error('環境変数の検証に失敗しました:');
      error.issues.forEach((issue) => {
        console.error(
          `- ${issue.path.join('.')}: ${issue.message}`
        );
      });
    }
    process.exit(1);
  }
}
typescript// アプリケーション起動時に検証
const env = getValidatedEnv();

// 型安全に環境変数を使用
console.log(`サーバーをポート ${env.PORT} で起動します`);
console.log(`環境: ${env.NODE_ENV}`);

if (env.ENABLE_ANALYTICS) {
  console.log('アナリティクスが有効です');
}

このパターンにより、アプリケーション起動時に環境変数の不備を即座に検出できます。

複雑なデータ構造の境界防衛

ネストした複雑なデータ構造も、Zod で階層的に検証できます。

typescript// 配列のスキーマ
const TagSchema = z.string().min(1).max(20);

// ネストしたスキーマ
const ArticleSchema = z.object({
  id: z.string().uuid(),
  title: z.string().min(10).max(200),
  content: z.string().min(100),
  author: z.object({
    id: z.string().uuid(),
    name: z.string(),
    email: z.string().email(),
  }),
  tags: z.array(TagSchema).min(1).max(5),
  status: z.enum(['draft', 'published', 'archived']),
  publishedAt: z.string().datetime().nullable(),
  metadata: z.record(z.string(), z.unknown()),
});
typescript// 型を推論
type Article = z.infer<typeof ArticleSchema>;

// バリデーション例
function validateArticle(data: unknown): Article {
  return ArticleSchema.parse(data);
}

下図は、複雑なスキーマの構造を示しています。

mermaidflowchart TB
    article["ArticleSchema"]
    author["author object"]
    tags["tags array"]
    metadata["metadata record"]

    article --> author
    article --> tags
    article --> metadata

    author --> authorId["id: uuid"]
    author --> authorName["name: string"]
    author --> authorEmail["email: email format"]

    tags --> tag1["TagSchema"]
    tag1 --> tagValidation["string<br/>min:1, max:20"]

    metadata --> metadataKey["key: string"]
    metadata --> metadataValue["value: unknown"]

    style article fill:#e1f5ff
    style author fill:#fff4e1
    style tags fill:#e8f5e9
    style metadata fill:#fce4ec

図で理解できる要点:

  • 複雑なデータ構造も階層的にスキーマを定義できる
  • 各ネストレベルで独立したバリデーションが実行される
  • すべての階層で型推論が機能する

スキーマの合成と再利用

境界防衛のパターンを再利用可能なスキーマとして定義できます。

typescript// 共通スキーマの定義
const TimestampSchema = z.object({
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});

const BaseEntitySchema = z.object({
  id: z.string().uuid(),
});
typescript// スキーマの合成
const UserEntitySchema = BaseEntitySchema.merge(
  TimestampSchema
).extend({
  username: z.string(),
  email: z.string().email(),
});

const PostEntitySchema = BaseEntitySchema.merge(
  TimestampSchema
).extend({
  title: z.string(),
  content: z.string(),
  authorId: z.string().uuid(),
});
typescript// それぞれの型を推論
type UserEntity = z.infer<typeof UserEntitySchema>;
type PostEntity = z.infer<typeof PostEntitySchema>;

このように、共通のパターンを再利用することで、一貫した境界防衛を実現できます。

まとめ

Zod の設計思想は「境界を守る」ことに特化しています。実行時バリデーションと型推論を統合した二刀流アプローチにより、以下を実現します。

境界防衛の実現:

  • 外部データの入り口で確実にバリデーション
  • 不正なデータは境界で遮断
  • 内部ロジックには検証済みデータのみが流入

開発体験の向上:

  • スキーマを書けば型も自動生成される
  • バリデーションと型定義の二重管理が不要
  • 詳細なエラー情報により素早いデバッグが可能

型安全性の確保:

  • バリデーション通過後は完全な型推論
  • TypeScript のエディタサポートを最大限活用
  • リファクタリングも安全に実行可能

現代の Web アプリケーションでは、API、フォーム、環境変数など、あらゆる場所に境界が存在します。Zod を活用することで、これらの境界を確実に守り、内部では型安全なコードを書けるようになるのです。

単なるバリデーションライブラリとしてではなく、「境界を守る設計思想を実現するツール」として Zod を捉えることで、より堅牢なアプリケーションを構築できるでしょう。境界防衛という視点から、ぜひ Zod の導入を検討してみてください。

関連リンク