0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptとZodで型安全なAPIを構築!実践バリデーション設計のコツ

0
Posted at

多くのTypeScriptプロジェクトで、「コンパイルは通るのに、実行時にAPIレスポンスの型が違ってエラーになる」「ユーザー入力のバリデーションが甘くて脆弱性につながる」といった問題に直面していませんか? TypeScriptの型システムはコンパイル時の安全性を保証しますが、外部からのデータ(APIレスポンス、ユーザー入力など)のランタイムバリデーションまではカバーできません。

この記事では、そんな課題を解決する強力なライブラリ Zod を使い、TypeScriptとZodで型安全なAPIを構築するための具体的な設計パターンと実装のコツを徹底解説します。Zodの基本的な使い方から、実務で役立つ応用テクニック、そしてよくあるハマりどころとその回避策まで、TypeScriptランタイムバリデーションのベストプラクティスを網羅的にご紹介します。


Zodとは?TypeScriptプロジェクトでランタイムバリデーションが必要な理由

このセクションでは、Zodの概要と、なぜTypeScriptプロジェクトにおいてランタイムバリデーションが不可欠なのかを解説します。

Zodは、TypeScriptファーストのスキーマ宣言およびバリデーションライブラリです。その主な目的は、コンパイル時の型安全性とランタイムバリデーションのギャップを埋めることにあります。

TypeScriptは強力な静的型付け言語であり、コードの記述時に多くの型エラーを発見できます。しかし、外部から取得するデータ(例えば、REST APIのレスポンス、データベースからのデータ、ユーザーからのフォーム入力、環境変数など)は、常に期待通りの型や構造をしているとは限りません。悪意のある入力や、APIの仕様変更、データ破損など、さまざまな要因で予期しないデータが流入する可能性があります。

このような「信頼できないデータ」に対してTypeScriptの型アサーション(as Type)を安易に使うと、コンパイルは通ってもランタイムでエラーが発生したり、セキュリティ上の脆弱性につながったりするリスクがあります。Zodは、これらの外部データがアプリケーション内で使用される前に、期待されるスキーマと一致することをランタイムで保証します。

Zodの主要な特徴 (v3.23.3 以降)

  • TypeScript-First: スキーマ定義からTypeScriptの型を自動推論するため、型定義の重複が不要です。
  • 強力なバリデーション機能: プリミティブ型から複雑なオブジェクト、配列、ユニオン、リテラル、列挙型、日付、UUIDまで、多様なデータ型に対応し、豊富なバリデーションメソッドを提供します。
  • カスタムバリデーションと変換: .refine() で複雑なビジネスロジックを追加したり、.transform() でデータの正規化・変換を行ったりできます。
  • 優れた開発者体験: 直感的で宣言的なAPIにより、スキーマ定義が容易です。
  • エコシステム: tRPC, React Hook Form, Next.js, Expressなど、多くの人気ライブラリやフレームワークと連携が可能です。

インストール方法

Zodはnpmまたはyarnで簡単にインストールできます。

npm install zod
# または
yarn add zod

Zodの基本的なスキーマ定義とバリデーション

このセクションでは、Zodを使った基本的なスキーマの定義方法、TypeScriptの型推論、そしてバリデーションの実行とエラーハンドリングの基本を学びます。

スキーマの定義と型推論

Zodでは、z.object()z.string()z.number() などのAPIを使ってデータのスキーマを定義します。

import { z } from 'zod';

// ユーザーデータのスキーマを定義
const UserSchema = z.object({
  id: z.string().uuid("無効なUUID形式です"), // UUID形式の文字列
  name: z.string().min(1, '名前は必須です'), // 1文字以上の文字列
  email: z.string().email('無効なメールアドレス形式です'), // メールアドレス形式の文字列
  age: z.number().int('年齢は整数である必要があります').positive('年齢は正の数である必要があります').optional(), // 正の整数、オプション
  createdAt: z.string().datetime('無効な日時形式です'), // ISO 8601形式の日時文字列
  status: z.enum(['active', 'inactive'], { error_map: (issue, ctx) => ({ message: "ステータスは'active'または'inactive'である必要があります" }) }), // 列挙型
});

// ZodスキーマからTypeScriptの型を自動推論
type User = z.infer<typeof UserSchema>;

// 推論されたUser型は以下のようになる
/*
type User = {
    id: string;
    name: string;
    email: string;
    age?: number | undefined;
    createdAt: string;
    status: "active" | "inactive";
}
*/

// 有効なデータ例
const validUserData = {
  id: 'a1b2c3d4-e5f6-7890-1234-567890abcdef',
  name: 'John Doe',
  email: 'john.doe@example.com',
  age: 30,
  createdAt: '2023-01-01T10:00:00Z',
  status: 'active' as const, // enum型の場合、as const をつけるとより厳密な型推論が可能
};

z.infer<typeof YourSchema> を使用することで、Zodスキーマから対応するTypeScriptの型を自動的に抽出できます。これにより、型定義の重複を避け、常にZodスキーマがTypeScriptの型定義の「唯一の真実の源」となります。

バリデーションの実行とエラーハンドリング

Zodには、バリデーションを実行するためのメソッドがいくつか用意されています。

  • parse(data: unknown): 入力を検証し、有効な場合は型付けされた結果を返します。無効な場合は ZodError をスローします。
  • safeParse(data: unknown): エラーをスローせずに結果オブジェクトを返します。成功したデータ、または ZodError を含む結果を返します。ユーザー入力のバリデーションに適しています。
  • parseAsync(data: unknown) / safeParseAsync(data: unknown): 非同期の refinetransform を使用するスキーマの場合に利用します。
// データのバリデーション(成功時)
try {
  const parsedUser: User = UserSchema.parse(validUserData);
  console.log('バリデーション成功:', parsedUser);
} catch (error) {
  console.error('バリデーションエラー:', error);
}

// 無効なデータ例
const invalidUserData = {
  id: 'invalid-uuid',
  name: '',
  email: 'invalid-email',
  age: -5,
  createdAt: '2023/01/01',
  status: 'pending' as const,
};

// データのバリデーション(失敗時 - parse()はエラーをスロー)
try {
  UserSchema.parse(invalidUserData);
} catch (error: any) {
  console.error('バリデーションエラー (parse):', error.errors);
  /*
  出力例:
  バリデーションエラー (parse): [
    { code: 'invalid_string', validation: 'uuid', message: '無効なUUID形式です', path: [ 'id' ] },
    { code: 'too_small', minimum: 1, type: 'string', inclusive: true, exact: false, message: '名前は必須です', path: [ 'name' ] },
    { code: 'invalid_string', validation: 'email', message: '無効なメールアドレス形式です', path: [ 'email' ] },
    { code: 'too_small', minimum: 0, type: 'number', inclusive: false, exact: false, message: '年齢は正の数である必要があります', path: [ 'age' ] },
    { code: 'invalid_string', validation: 'datetime', message: '無効な日時形式です', path: [ 'createdAt' ] },
    { code: 'invalid_enum_value', received: 'pending', expected: [ 'active', 'inactive' ], message: "ステータスは'active'または'inactive'である必要があります", path: [ 'status' ] }
  ]
  */
}

// データのバリデーション(失敗時 - safeParse()はエラーオブジェクトを返す)
const safeParseResult = UserSchema.safeParse(invalidUserData);
if (!safeParseResult.success) {
  console.error('バリデーションエラー (safeParse):', safeParseResult.error.errors);
}

parse() はバリデーションに失敗するとエラーをスローするため、try/catch ブロックで囲む必要があります。一方、safeParse()success プロパティを持つオブジェクトを返すため、エラーハンドリングがより簡潔になります。ユーザー入力など、エラーが頻繁に発生しうる場面では safeParse() の使用が推奨されます。

APIレスポンスとリクエストのZodバリデーション実践

このセクションでは、APIレスポンスの受信時とAPIリクエストの送信時に、Zodを使ってどのようにデータ構造の保証と型安全性を高めるか具体的なコード例で示します。

APIレスポンスのバリデーション

外部APIからのレスポンスは、最も信頼できないデータの典型例です。Zodを使ってこれをバリデーションすることで、アプリケーション内部での型安全性を確保します。

import { z } from 'zod';

// 投稿データのスキーマを定義
const PostSchema = z.object({
  userId: z.number().int().positive(),
  id: z.number().int().positive(),
  title: z.string().min(1),
  body: z.string().min(1),
});

// スキーマからTypeScriptの型を推論
type Post = z.infer<typeof PostSchema>;

async function fetchPost(postId: number): Promise<Post> {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`);
  const data: unknown = await response.json(); // 外部データはunknown型で受け取るのがベストプラクティス

  // APIレスポンスをZodでバリデーション
  // 失敗するとZodErrorをスローするため、呼び出し元でtry/catchまたはsafeParseAsyncを使用
  const validatedPost = await PostSchema.parseAsync(data); // 非同期処理を考慮しparseAsyncを使用
  return validatedPost;
}

// 使用例
fetchPost(1)
  .then(post => console.log('取得した投稿:', post))
  .catch(error => {
    if (error instanceof z.ZodError) {
      console.error('投稿のバリデーションエラー:', error.errors);
    } else {
      console.error('投稿の取得エラー:', error);
    }
  });

// 存在しないIDで試す(APIは空のオブジェクトを返すためバリデーションエラーになる)
fetchPost(9999)
  .then(post => console.log('取得した投稿:', post))
  .catch(error => {
    if (error instanceof z.ZodError) {
      console.error('投稿のバリデーションエラー (存在しないID):', error.errors);
    } else {
      console.error('投稿の取得エラー (存在しないID):', error);
    }
  });

この例では、jsonplaceholder.typicode.com から取得した投稿データを PostSchema でバリデーションしています。fetchPost 関数は Post 型を返すと宣言していますが、実際にその型が保証されるのは Zod の parseAsync が成功した後です。これにより、APIレスポンスの構造が予期せず変更された場合でも、アプリケーションが安全に動作します。

部分的な更新(PATCHリクエストなど)のスキーマ

APIで部分的な更新(例えばユーザープロファイルのPATCHリクエスト)を扱う場合、リクエストボディの全てのフィールドが必須ではないことがあります。Zodの .partial() メソッドを使うと、既存のスキーマから全てのフィールドがオプションになった新しいスキーマを簡単に作成できます。

import { z } from 'zod';

// ユーザープロファイルスキーマの定義
const UserProfileSchema = z.object({
  username: z.string().min(3),
  bio: z.string().max(200).optional(),
  website: z.string().url().optional(),
});

// UserProfileSchemaの全てのフィールドをオプションにする
const UpdateUserProfileSchema = UserProfileSchema.partial();

type UpdateUserProfile = z.infer<typeof UpdateUserProfileSchema>;

// 部分更新データ例
const updates: UpdateUserProfile = {
  bio: '新しい自己紹介',
  // usernameやwebsiteは指定しなくても良い
};

try {
  UpdateUserProfileSchema.parse(updates);
  console.log('部分更新データバリデーション成功:', updates);
} catch (error) {
  console.error('部分更新データバリデーションエラー:', error);
}

UserProfileSchema.partial() は、元のスキーマの構造を維持しつつ、全てのプロパティをオプショナルにします。これにより、更新リクエストの柔軟性を保ちながら、バリデーションロジックの重複を防ぐことができます。

Zodの実践的な機能とハマりどころ、回避策

このセクションでは、Zodの強力な機能であるカスタムバリデーションや変換、そして実務でよく遭遇する Zod バリデーションのハマりどころと、その効果的な回避策を紹介します。

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

Zodは .refine().transform() を提供しており、組み込みのバリデーションでは対応できない複雑な要件に対応できます。

  • .refine(): 任意のカスタムバリデーションロジックを追加できます。例えば、複数のフィールド間の依存関係を検証する場合などに使用します。
  • .transform(): バリデーションが成功した後にデータを変換・正規化します。例えば、文字列を数値に変換したり、日付文字列を Date オブジェクトに変換したりできます。
import { z } from 'zod';

const EventSchema = z.object({
  startDate: z.string().datetime(),
  endDate: z.string().datetime(),
  title: z.string().min(5),
}).refine(data => new Date(data.startDate) < new Date(data.endDate), {
  message: "開始日は終了日より前である必要があります",
  path: ["startDate", "endDate"], // エラーメッセージに含めるパス
});

type Event = z.infer<typeof EventSchema>;

const validEvent = {
  startDate: '2023-10-26T10:00:00Z',
  endDate: '2023-10-26T11:00:00Z',
  title: 'Zod Workshop',
};

const invalidEvent = {
  startDate: '2023-10-26T12:00:00Z',
  endDate: '2023-10-26T11:00:00Z', // 開始日より前
  title: 'Short', // 5文字未満
};

try {
  EventSchema.parse(validEvent);
  console.log('イベントバリデーション成功:', validEvent);
} catch (error: any) {
  console.error('イベントバリデーションエラー:', error.errors);
}

try {
  EventSchema.parse(invalidEvent);
} catch (error: any) {
  console.error('イベントバリデーションエラー (invalid):', error.errors);
}

// transform の例: 数値文字列を数値に変換
const StringToNumberSchema = z.string().transform((val) => Number(val));
console.log('変換後:', StringToNumberSchema.parse("123")); // 123 (number)

.refine() は、スキーマ全体の整合性を検証するのに非常に強力です。また、.transform() を使うことで、入力された文字列を数値やDateオブジェクトに変換するといった前処理をバリデーションフローに組み込むことができます。

よくあるエラー・ハマりどころと回避策

1. フォーム入力での z.coerce.number()NaN の問題

ハマりどころ: HTMLフォームからの入力はすべて文字列として扱われます。z.coerce.number() は文字列を数値に変換しようとしますが、空文字列 ("") や無効な数値文字列は NaN に変換されます。Zodの .min().max() などの数値バリデーションは NaN に対しては期待通りに動作せず、汎用的なエラーメッセージが表示されることがあります。

回避策: z.preprocess() を使用して、Zodがバリデーションを行う前に値を前処理し、空文字列や NaNundefined に変換します。

import { z } from 'zod';

const FormSchema = z.object({
  readTime: z.preprocess(
    (val) => {
      // 空文字列またはundefinedの場合はundefinedを返す
      if (val === "" || val === undefined) return undefined;
      // 数値に変換を試みる
      const num = Number(val);
      // NaNの場合はundefinedを返す
      return isNaN(num) ? undefined : num;
    },
    z.number()
      .min(2, '最小読書時間は2分です')
      .max(60, '最大読書時間は60分です')
      .optional() // undefinedを許容する場合
  ),
});

// テスト
console.log('空文字列:', FormSchema.safeParse({ readTime: "" })); // { success: true, data: { readTime: undefined } }
console.log('無効な文字列:', FormSchema.safeParse({ readTime: "abc" })); // { success: true, data: { readTime: undefined } }
console.log('最小値未満:', FormSchema.safeParse({ readTime: "1" })); // { success: false, error: ... "最小読書時間は2分です" }
console.log('有効な値:', FormSchema.safeParse({ readTime: "5" })); // { success: true, data: { readTime: 5 } }

z.preprocess() は、与えられた値をZodスキーマが処理する前に、カスタム関数で変換できる非常に便利な機能です。

2. TypeScriptの型とZodスキーマの不一致

ハマりどころ: TypeScriptの型はコンパイル時にのみ有効であり、ランタイムのデータ構造を保証しません。特にAPIレスポンスなどの外部データに対して、TypeScriptの型アサーション (as Type) を安易に使用すると、実際のデータが型と異なる場合にランタイムエラーが発生する可能性があります。

回避策: Zodスキーマを「信頼できる唯一の情報源」として定義し、z.infer<typeof YourSchema> を使用してTypeScriptの型を自動生成します。外部からの入力は unknown 型で受け取り、Zodでバリデーション後に型安全なデータとして扱います。

import { z } from 'zod';

// Zodスキーマを定義
const ProductSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1),
  price: z.number().positive(),
});

// ZodスキーマからTypeScriptの型を推論
type Product = z.infer<typeof ProductSchema>;

// APIから取得したデータ(信頼できない)
const apiResponse: unknown = { // unknown型で受け取るのがベストプラクティス
  id: '123', // UUIDではない
  name: 'Laptop',
  price: -100, // 負の数
};

try {
  // Zodでバリデーションし、型安全なデータを得る
  const product: Product = ProductSchema.parse(apiResponse);
  console.log('バリデーション成功:', product);
} catch (error: any) {
  console.error('バリデーションエラー:', error.errors);
  // TypeScriptの型アサーションだけではこのエラーは防げない
  // const product: Product = apiResponse as Product; // これはコンパイルエラーにならないが、ランタイムで問題を起こす可能性がある
}

このアプローチにより、開発者はコンパイル時の型チェックとランタイムのデータバリデーションの両方の恩恵を受け、堅牢なアプリケーションを構築できます。

3. エラーメッセージのカスタマイズが不十分

ハマりどころ: Zodのデフォルトのエラーメッセージは詳細ですが、ユーザーフレンドリーではない場合があります。特にフォーム入力など、ユーザーに直接フィードバックを返す必要がある場合に問題となります。

回避策:

  • スキーマ定義時にメッセージを指定: 各バリデーションメソッドに直接エラーメッセージを渡すことができます。
  • error_map オプションの使用: z.object()z.enum() などのスキーマ定義時に error_map オプションを渡すことで、より詳細なエラーメッセージをカスタマイズできます。
  • グローバルなエラーマップの設定: z.setErrorMap() を使用して、アプリケーション全体でZodのエラーメッセージをカスタマイズできます。
import { z, ZodErrorMap } from 'zod';

// 1. スキーマ定義時にメッセージを指定
const UserSchemaWithCustomMessages = z.object({
  username: z.string().min(3, 'ユーザー名は3文字以上である必要があります'),
  password: z.string().min(8, 'パスワードは8文字以上である必要があります').regex(/[A-Z]/, 'パスワードには大文字を含める必要があります'),
});

// 2. `error_map` オプションの使用 (z.enumの例)
const StatusSchema = z.enum(['active', 'inactive'], {
  error_map: (issue, ctx) => ({ message: `ステータスは'active'または'inactive'のいずれかである必要があります。入力値: ${ctx.data}` })
});

// 3. グローバルなエラーマップの設定
// アプリケーションの起動時に一度だけ設定することが推奨されます
const customErrorMap: ZodErrorMap = (issue, ctx) => {
  if (issue.code === z.ZodIssueCode.invalid_type) {
    if (issue.expected === "string") {
      return { message: "文字列形式で入力してください" };
    }
    if (issue.expected === "number") {
      return { message: "数値を入力してください" };
    }
  }
  if (issue.code === z.ZodIssueCode.too_small && issue.type === "string") {
    return { message: `このフィールドは最低${issue.minimum}文字必要です` };
  }
  // デフォルトのエラーメッセージを返す
  return { message: ctx.defaultError };
};
z.setErrorMap(customErrorMap); // アプリケーションの初期化時に一度だけ実行

// テスト
console.log('カスタムメッセージ付きスキーマ:', UserSchemaWithCustomMessages.safeParse({ username: 'ab', password: 'password' }));
console.log('enumのerror_map:', StatusSchema.safeParse('pending'));
console.log('グローバルエラーマップ適用:', z.string().safeParse(123)); // グローバルエラーマップが適用される

これらのカスタマイズオプションを適切に利用することで、よりユーザーフレンドリーで分かりやすいエラーメッセージを提供し、開発者体験とエンドユーザー体験の両方を向上させることができます。

Zodを利用した設計上のベストプラクティスとトレードオフ

このセクションでは、Zodを最大限に活用するための設計上のベストプラクティスと、Zod導入に伴うトレードオフについて解説します。

ベストプラクティス

  • TypeScript-Firstの原則: Zodスキーマを定義し、そこから z.infer でTypeScriptの型を生成することで、型定義の重複を避け、コンパイル時とランタイムの型安全性を一致させます。
  • APIの入力と出力のバリデーション: 信頼できない外部データ(APIリクエストのペイロード、APIレスポンス、フォーム入力、環境変数など)は必ずZodでバリデーションします。これにより、アプリケーションの堅牢性とセキュリティが向上します。
  • safeParse() の活用: ユーザー入力やAPIレスポンスなど、バリデーションエラーが頻繁に発生しうる場面では、try/catch ブロックを避けるために safeParse() を使用してエラーを処理します。
  • スキーマの再利用とモジュール化: 共通のバリデーションルールを持つスキーマは再利用可能なコンポーネントとして抽出し、ドメインごとに整理します。これにより、コードの可読性と保守性が向上します。
  • .transform() でデータの正規化: バリデーションと同時にデータの変換や正規化が必要な場合は、.transform() を活用します。
  • .refine() でカスタムロジック: Zodの組み込みバリデーションでは対応できない複雑なビジネスロジックや相互依存するフィールドのバリデーションには、.refine() を使用します。
  • エラーメッセージのカスタマイズ: ユーザーに分かりやすいエラーメッセージを提供するために、スキーマ定義時やグローバルなエラーマップでメッセージをカスタマイズします。
  • any 型の回避: any 型を使用するとZodの型安全性のメリットが失われるため、Zodスキーマと連携する際には any 型の使用を避け、外部からの入力は unknown 型で受け取ります。
  • 認証とバリデーションの分離: Zodはデータの「形」を検証しますが、呼び出し元がその操作を許可されているか(認証/認可)は検証しません。認証・認可のロジックはスキーマバリデーションとは別に実装します。

トレードオフ

  • ランタイムオーバーヘッド: Zodによるランタイムバリデーションは、追加のコード実行を伴うため、わずかながらパフォーマンスのオーバーヘッドが発生します。ただし、Zodは効率的に設計されており、ほとんどのアプリケーションでは問題になりません。大量のデータを処理する場合など、パフォーマンスが非常に重要な場面では、Zodの利用方法(例: ホットループ内でのスキーマ解析を避ける)に注意が必要です。
  • 学習コスト: ZodのAPIは直感的ですが、初めて使用する開発者には学習コストがかかります。特に複雑なスキーマやカスタムバリデーションを扱う場合、Zodの概念を理解する必要があります。
  • スキーマ定義の冗長性: 非常にシンプルなデータ構造の場合、Zodスキーマを定義することがTypeScriptのインターフェースや型エイリアスを直接定義するよりも冗長に感じられることがあります。しかし、ランタイムバリデーションの必要性を考慮すると、この冗長性は正当化されます。
  • Zodのバージョンアップによる変更: Zod 4のように、メジャーバージョンアップで破壊的変更が入ることがあります。これにより、既存コードの修正が必要になる可能性がありますが、通常はパフォーマンス向上や機能改善が伴います(Zod v3.23.3時点では、z.email()のようなトップレベルフォーマットバリデータはまだ利用できず、v4で導入予定です)。

まとめ

この記事では、TypeScriptプロジェクトにおけるランタイムバリデーションの重要性と、Zodを使った具体的な実装方法、そして実践的な設計パターンについて解説しました。

  • Zodは、TypeScriptの静的型チェックではカバーできないランタイムの型安全性を保証します。
  • z.infer を活用することで、Zodスキーマを型定義の唯一の真実の源とすることができます。
  • APIレスポンスやユーザー入力など、信頼できない外部データは必ずZodでバリデーションすることで、アプリケーションの堅牢性とセキュリティを高めます。
  • safeParse().refine().transform()z.preprocess() などの機能を使うことで、複雑なバリデーション要件やデータ変換にも柔軟に対応できます。
  • Zodの導入にはわずかな学習コストとランタイムオーバーヘッドがありますが、それ以上に得られる型安全性と開発者体験の向上は、特に大規模なアプリケーションにおいて大きなメリットとなります。

Zodをプロジェクトに導入することで、より堅牢で保守性の高いアプリケーション開発が可能になります。ぜひ、この記事を参考に、ご自身のプロジェクトで Zod を活用してみてください。

より詳細な情報や発展的なトピックについては、Zodの公式ドキュメントも参照してください。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?