多くのTypeScript開発者がAPIレスポンスやフォーム入力のバリデーションで直面する課題、それは「型安全性の維持とランタイムバリデーションの両立」です。コンパイル時の型チェックだけでは、外部からの不正なデータやAPIのスキーマ変更には対応しきれません。かといって、実行時バリデーションを手書きすると型定義との二重管理になりがちです。
この記事では、ZodとValibotという2つの強力なスキーマバリデーションライブラリを比較し、TypeScriptプロジェクトで型安全なAPIを効率的に構築するための具体的な手順と実践的な設計パターンを解説します。どちらのライブラリがあなたのプロジェクトに最適か、明確な判断基準とともにお届けします。
TypeScriptプロジェクトにおけるスキーマバリデーションの重要性
現代のWebアプリケーション開発において、データの信頼性は極めて重要です。特に、外部APIからのデータやユーザーからのフォーム入力は、常に期待通りの形式であるとは限りません。ここでスキーマバリデーションが不可欠となります。TypeScriptでアプリケーションを構築している場合、コンパイル時の型チェックだけでは不十分であり、実行時(ランタイム)にデータの形式が正しいことを保証する必要があります。
ZodやValibotのようなライブラリは、単にデータを検証するだけでなく、そのスキーマ定義からTypeScriptの型定義を自動生成する「TypeScript-first」なアプローチを提供します。これにより、型定義の重複を排除し、開発体験(DX)を大幅に向上させつつ、アプリケーションの堅牢性を高めることができます。
ZodとValibotの比較:どちらを選ぶべきか
ZodとValibotは、どちらもTypeScriptで強力なスキーマバリデーションを提供するライブラリですが、設計思想や特徴に違いがあります。ここでは、それぞれの特性を詳しく比較し、プロジェクトのニーズに応じた選択の指針を示します。
Zodの強みと特徴 (v3.23.3)
Zodは、その豊富なエコシステムと直感的なAPIで多くの開発者に支持されています。
-
TypeScript-first: スキーマ定義からTypeScript型を自動推論し、型定義の重複を防ぎます。
z.infer<typeof schema>を使用することで、常にスキーマと型が同期します。 - 豊富なエコシステム: tRPC、React Hook Form、Next.js Server Actionsなど、主要なライブラリやフレームワークとの連携が非常に強力です。
-
直感的なAPI: メソッドチェーン形式のAPIは非常に読みやすく、スキーマを定義しやすいのが特徴です(例:
z.string().email())。 -
カスタムバリデーション:
.refine()や.superRefine()を使って、独自の複雑なバリデーションロジックを容易に追加できます。 - バンドルサイズ: 約12KB (gzipped)。サーバーサイド環境ではほとんど問題になりませんが、エッジ環境では考慮が必要です。
Valibotの強みと特徴 (v0.30.0)
Valibotは、Zodの強力な機能性を保ちつつ、軽量さとパフォーマンスを追求した新しい選択肢です。
- 軽量・高速: バンドルサイズは1KB未満と非常に小さく、特に大規模なスキーマやパフォーマンスが重視される環境で優位性があります。
- モジュラーアーキテクチャ: 各バリデーターが個別のインポートとして提供されるため、使用する機能のみがバンドルに含まれ、積極的なツリーシェイキングが可能です。
-
関数合成API:
pipe(string(), email())のように、関数を合成するスタイルでスキーマを定義します。関数型プログラミングに慣れた開発者には馴染みやすいでしょう。 -
非同期バリデーション:
parseAsync関数により、非同期処理を含むバリデーションもサポートします。 - Standard Schema互換性: ベンダーニュートラルなバリデーション関数と型を提供し、他の互換ライブラリとの連携も視野に入れています。
ZodとValibotの選択基準
プロジェクトの特性に応じて、どちらのライブラリが適しているか判断しましょう。
Zodを選ぶべきケース
- 既存のZodエコシステムとの統合: tRPCやReact Hook Formなど、Zodを前提としたライブラリを既に利用している、または利用する予定がある場合。
- サーバーサイド中心の開発: バンドルサイズがそれほどクリティカルではないNode.js環境など。
- 開発者体験 (DX) を重視: 直感的なメソッドチェーンAPIを好み、豊富なドキュメントやコミュニティサポートを求める場合。
Valibotを選ぶべきケース
- ブラウザやエッジ環境: Cloudflare Workersなどのエッジ関数、またはバンドルサイズと起動速度が極めて重要なフロントエンドアプリケーション。
- 新しいプロジェクト: 既存の依存関係がなく、軽量でパフォーマンスを重視したい場合。
- 関数型APIを好む: モジュラーで関数合成ベースのAPIスタイルが開発者に馴染む場合。
Zodによる型安全なスキーマバリデーションの実装例
ここでは、Zodを使った具体的なスキーマ定義、データのバリデーション、そしてエラーハンドリングの手順を解説します。
前提・環境
- Node.js: v18以上
- npm/yarn/pnpm
- TypeScript: v5.0以上
- Zod: v3.23.3
導入手順
まずZodをインストールします。
npm install zod
# または yarn add zod
# または pnpm add zod
スキーマ定義とデータバリデーション
Zodでは、z.object を使ってオブジェクトスキーマを定義し、各プロパティにバリデーターをメソッドチェーンで繋げていきます。
import { z, ZodError } from "zod";
// ユーザー情報のスキーマ定義
// 各バリデーターに直接エラーメッセージを渡すことで、ユーザーフレンドリーなメッセージを設定できます。
const UserSchema = z.object({
name: z.string().min(1, "名前は必須です"),
age: z.number().int("年齢は整数である必要があります").positive("年齢は正の整数である必要があります"),
email: z.string().email("有効なメールアドレスではありません"),
isAdmin: z.boolean().optional(), // オプションフィールドは .optional() で指定
profileUrl: z.string().url("有効なURLではありません").optional(),
});
// スキーマからTypeScript型を推論
// これにより、スキーマと型定義の整合性が保証されます。
type User = z.infer<typeof UserSchema>;
// 有効なデータの例
const validUserData = {
name: "John Doe",
age: 30,
email: "john.doe@example.com",
};
try {
// .parse() メソッドでデータを検証し、型安全なオブジェクトを取得
const parsedUser: User = UserSchema.parse(validUserData);
console.log("データは有効です:", parsedUser);
} catch (error) {
if (error instanceof ZodError) {
// ZodErrorはバリデーションエラーの詳細な情報を持つ
console.error("無効なデータです:", error.errors);
} else {
console.error("予期せぬエラー:", error);
}
}
// 無効なデータの例
const invalidUserData = {
name: "", // min(1) に違反
age: -5, // positive に違反
email: "invalid-email", // email形式に違反
};
try {
UserSchema.parse(invalidUserData);
} catch (error) {
if (error instanceof ZodError) {
console.error("無効なデータです:", error.errors);
/*
出力例:
無効なデータです: [
{ code: 'too_small', minimum: 1, type: 'string', inclusive: true, exact: false, message: '名前は必須です', path: [ 'name' ] },
{ code: 'too_small', minimum: 0, type: 'number', inclusive: false, exact: false, message: '年齢は正の整数である必要があります', path: [ 'age' ] },
{ validation: 'email', code: 'invalid_string', message: '有効なメールアドレスではありません', path: [ 'email' ] }
]
*/
} else {
console.error("予期せぬエラー:", error);
}
}
APIからのデータ検証
APIから取得したデータも同様にZodでバリデーションすることで、アプリケーション内部での型安全性を確保できます。
// APIからのデータ検証例
async function fetchDataWithZod() {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/users/1');
const data = await response.json();
// 取得したJSONデータをUserSchemaで検証
const parsedData = UserSchema.parse(data);
console.log('APIデータは有効です:', parsedData);
} catch (error: unknown) { // errorの型をunknownにすることで、より安全な型チェックを促す
if (error instanceof ZodError) {
console.error('APIデータが無効です:', error.errors);
} else {
console.error('APIデータ取得またはパース中に予期せぬエラー:', error);
}
}
}
fetchDataWithZod();
Valibotによる型安全なスキーマバリデーションの実装例
次に、Valibotを使ったスキーマ定義、バリデーション、エラーハンドリングの具体例を見ていきましょう。
前提・環境
- Node.js: v18以上
- npm/yarn/pnpm
- TypeScript: v5.0以上
- Valibot: v0.30.0
導入手順
Valibotをインストールします。
npm install valibot
# または yarn add valibot
# または pnpm add valibot
スキーマ定義とデータバリデーション
Valibotでは、v.object や v.pipe を使ってスキーマを定義します。pipe は複数のバリデーター関数を結合する際に使用します。
import * as v from 'valibot';
import { ValiError } from 'valibot'; // ValiErrorを明示的にインポート
// ユーザー情報のスキーマ定義
const UserSchema = v.object({
name: v.string("名前は必須です"), // バリデーター関数に直接エラーメッセージを渡す
// pipeを使って複数のバリデーションを適用
age: v.pipe(v.number(), v.integer("年齢は整数である必要があります"), v.minValue(1, "年齢は正の整数である必要があります")),
email: v.pipe(v.string(), v.email("有効なメールアドレスではありません")),
isAdmin: v.optional(v.boolean()), // オプションフィールドは v.optional() でラップ
profileUrl: v.optional(v.pipe(v.string(), v.url("有効なURLではありません"))),
});
// スキーマからTypeScript型を推論
// Zodと同様に、型定義の自動生成により整合性を保ちます。
type User = v.InferOutput<typeof UserSchema>;
// 有効なデータの例
const validUserData = {
name: "Jane Doe",
age: 25,
email: "jane.doe@example.com",
};
try {
// v.parse() 関数でデータを検証
const parsedUser: User = v.parse(UserSchema, validUserData);
console.log("データは有効です:", parsedUser);
} catch (error) {
if (error instanceof ValiError) {
// ValiErrorは issues プロパティにエラーの詳細情報を持つ
console.error("無効なデータです:", error.issues);
} else {
console.error("予期せぬエラー:", error);
}
}
// 無効なデータの例
const invalidUserData = {
name: "", // string() に違反
age: 0, // minValue(1) に違反
email: "invalid-email", // email() に違反
};
try {
v.parse(UserSchema, invalidUserData);
} catch (error: unknown) { // errorの型をunknownにすることで、より安全な型チェックを促す
if (error instanceof ValiError) {
console.error("無効なデータです:", error.issues);
/*
出力例:
無効なデータです: [
{ reason: 'string', validation: 'min_length', origin: 'value', message: '名前は必須です', input: '', path: [ { type: 'object', input: [Object], key: 'name' } ] },
{ reason: 'number', validation: 'min_value', origin: 'value', message: '年齢は正の整数である必要があります', input: 0, path: [ { type: 'object', input: [Object], key: 'age' } ] },
{ reason: 'string', validation: 'email', origin: 'value', message: '有効なメールアドレスではありません', input: 'invalid-email', path: [ { type: 'object', input: [Object], key: 'email' } ] }
]
*/
} else {
console.error("予期せぬエラー:", error);
}
}
APIからのデータ検証
ValibotでもZodと同様に、APIから取得したデータのバリデーションを行うことで、ランタイムでの型安全性を保証できます。
// APIからのデータ検証例
async function fetchDataWithValibot() {
try {
const response = await fetch('https://jsonplaceholder.typicode.com/users/1');
const data = await response.json();
// 取得したJSONデータをUserSchemaで検証
const parsedData = v.parse(UserSchema, data);
console.log('APIデータは有効です:', parsedData);
} catch (error: unknown) {
if (error instanceof ValiError) {
console.error('APIデータが無効です:', error.issues);
} else {
console.error('APIデータ取得またはパース中に予期せぬエラー:', error);
}
}
}
fetchDataWithValibot();
スキーマバリデーションにおけるよくあるエラーと回避策
ZodやValibotのような強力なツールを使っても、いくつかの一般的な問題に直面することがあります。ここでは、それらの問題と効果的な回避策を紹介します。
1. ランタイムとコンパイル時の型の不一致
ハマりどころ: TypeScriptはコンパイル時に型チェックを行いますが、ZodやValibotは実行時にデータのバリデーションを行います。スキーマ定義と手動で書いたTypeScriptの型定義が乖離していると、実行時エラーを見逃す可能性があります。
回避策:
Zodの z.infer<typeof Schema> やValibotの v.InferOutput<typeof Schema> を常に使用し、スキーマから直接TypeScript型を生成してください。これにより、型定義の重複を避け、常にスキーマと型が同期している状態を維持できます。これは、型安全なAPIを構築する上での最も重要なプラクティスの一つです。
2. 複雑なスキーマのエラーメッセージの扱い
ハマりどころ: ネストされたオブジェクトや配列など、複雑なスキーマでバリデーションエラーが発生した場合、デフォルトのエラーメッセージは詳細すぎてユーザーに分かりにくいことがあります。
回避策:
-
Zod: 各バリデーターメソッドに直接エラーメッセージを渡すことができます(例:
z.string().min(1, "名前は必須です"))。より複雑なカスタムバリデーションには.refine()や.superRefine()を使用し、カスタムエラーメッセージを設定できます。ZodErrorオブジェクトのflatten()メソッドを使ってエラーメッセージを整形することも有効です。 -
Valibot: 各バリデーター関数に直接エラーメッセージを渡すことができます(例:
v.string("名前は必須です"))。ValiErrorオブジェクトのissuesプロパティから詳細なエラー情報を取得し、フロントエンドで表示しやすい形に整形する処理を実装します。 - 国際化 (i18n) を考慮し、エラーメッセージを一元管理する仕組みを導入することも検討しましょう。
3. パフォーマンスの懸念(特に大規模なデータやエッジ環境)
ハマりどころ: 大量のデータをバリデーションする場合や、Cloudflare Workersのようなエッジ環境でバンドルサイズや起動速度が重視される場合、バリデーションライブラリのオーバーヘッドが問題になることがあります。
回避策:
- Valibotの活用: ValibotはZodに比べてバンドルサイズが非常に小さく、起動性能も優れています。エッジ関数やブラウザにバンドルされるバリデーターにはValibotが特に適しています。
- 不要なバリデーションの回避: 一度バリデーションされた信頼できるデータは、再度バリデーションしないように設計します。API境界で一度バリデーションすれば、システム内部ではそのデータを信頼できるものとして扱えます。
- ホットループ外でのスキーマパース: 大量のアイテムをバリデーションする場合、ホットループ内で個別にパースするのではなく、配列スキーマとして一括でパースするなど、処理を最適化します。
-
Zod Miniの検討: Zod v4で導入予定の
zod/v4-miniを使用することで、Zodのバンドルサイズを削減できる可能性がありますが、これは将来の機能であり、現在の安定版では利用できません。公式ドキュメントで最新情報を確認してください。
スキーマバリデーションの設計上のトレードオフとベストプラクティス
ここでは、ZodやValibotを使ったスキーマバリデーションをプロジェクトに組み込む際の設計指針と、堅牢なアプリケーションを構築するためのベストプラクティスを解説します。
スキーマの再利用とモジュール化
APIリクエスト、フォーム入力、設定ファイルなど、異なるコンテキストで同じデータ構造を扱うことはよくあります。このような場合、スキーマを再利用することでコードの重複を減らし、保守性を高めます。
ベストプラクティス:
- スキーマを独立したファイル (
schemas/userSchema.tsなど) に定義し、必要に応じてインポートして使用します。 - 共通のベーススキーマを定義し、それを拡張する形で特定のユースケースに合わせたスキーマを作成します。例えば、
UserBaseSchemaを定義し、CreateUserSchemaやUpdateUserSchemaでそれを拡張する形です。
API境界での厳格なバリデーション
アプリケーションの信頼境界(APIエンドポイント、外部サービスからの入力、ユーザー入力など)でデータを厳密にバリデーションすることが、セキュリティと堅牢性を高める上で最も重要です。
ベストプラクティス:
- 入力の厳格な検証: 外部からの入力は常に疑い、アプリケーション内部に入る前にZodやValibotで徹底的にバリデーションします。
- 出力の検証(オプション): 外部に返すAPIレスポンスもバリデーションすることで、意図しないデータ漏洩や形式の崩れを防ぐことができます。ただし、パフォーマンスとのトレードオフを考慮し、必要な場合に限定します。
エラーハンドリング戦略の確立
ユーザーフレンドリーなエラーメッセージを提供し、開発者がデバッグしやすいように、バリデーションエラーを適切に捕捉し、整形する戦略を立てましょう。
ベストプラクティス:
- フロントエンド: フォームの各フィールドに対応するエラーメッセージを表示し、ユーザーがすぐに修正できるようにガイドします。
- バックエンド: APIレスポンスとして、エラーコードと詳細なメッセージを含む構造化されたエラー情報を返します。これにより、フロントエンドがエラーを処理しやすくなります。
- エラーロギング: サーバーサイドでは、発生したバリデーションエラーを適切にログに記録し、監視システムと連携させることで、潜在的な問題を早期に発見できます。
トランスフォーメーションの活用
ZodやValibotは、バリデーションと同時にデータの変換(トランスフォーメーション)を行うことができます。これにより、入力データのクレンジングとバリデーションを一体的に行え、コードを簡潔に保てます。
Zodの場合: .transform() メソッドを使用します。
const NumberAsStringSchema = z.string().transform((val) => Number(val));
NumberAsStringSchema.parse("123"); // 123 (number型)
Valibotの場合: v.transform() 関数を使用します。
import * as v from 'valibot';
const NumberAsStringSchema = v.pipe(v.string(), v.transform((val) => Number(val)));
v.parse(NumberAsStringSchema, "123"); // 123 (number型)
例えば、APIからの日付文字列をJavaScriptの Date オブジェクトに変換したり、ユーザー入力の文字列をトリムしたりする際に非常に便利です。
まとめと次の一歩
この記事では、TypeScriptプロジェクトで型安全なAPIを構築するためのスキーマバリデーションとして、ZodとValibotの導入手順、具体的な実装例、そしてよくある課題とその回避策を解説しました。
- Zodは、豊富なエコシステムと直感的なメソッドチェーンAPIで、広範なプロジェクト、特に既存のZod依存関係がある場合に強力な選択肢です。
- Valibotは、軽量さとパフォーマンス、モジュラーな設計が特徴で、エッジ環境やバンドルサイズが重要な新しいプロジェクトに最適です。
どちらのライブラリを選択するにしても、スキーマからTypeScript型を自動生成する機能 (z.infer / v.InferOutput) を最大限に活用し、API境界で厳格なバリデーションを行うことが、堅牢で保守性の高いアプリケーションを構築する鍵となります。
次の一歩として、各ライブラリの公式ドキュメントでさらに詳細な機能や応用例を確認し、あなたのプロジェクトに最適なバリデーション戦略を実装してみてください。