「TypeScriptを使っているのに、なぜか実行時に型エラーで落ちる…」多くのTypeScript開発者が一度は経験するこの悩ましい問題。コンパイル時には問題なかったはずのデータが、APIレスポンスやフォーム入力などの「外部からの入力」によって予期せぬ型不一致を引き起こし、アプリケーションがクラッシュするケースは少なくありません。
この記事では、そんなTypeScript実行時の型エラーを根本から解決するスキーマバリデーションライブラリZodに焦点を当てます。Zodを導入する際に多くのエンジニアが陥りがちな3つの落とし穴と、それらを回避するための具体的なコードとベストプラクティスを解説します。この記事を読めば、あなたのTypeScriptアプリケーションは実行時も堅牢な型安全性を手に入れられるでしょう。
TypeScriptが解決できない実行時型不一致の問題
TypeScriptは強力な静的型チェックを提供しますが、そのチェックはコードがコンパイルされる時点でのみ有効です。しかし、現実世界のアプリケーションでは、以下のような「外部からのデータ」を常に扱います。
- APIからのJSONレスポンス
- ユーザーからのフォーム入力
- データベースからの取得データ
- 環境変数
これらのデータは、TypeScriptコンパイラがチェックできる範囲外で生成・提供されるため、定義した型と実際のデータ構造が異なる可能性があります。例えば、number型を期待している場所にstringが来たり、必須プロパティが欠落していたりすると、コンパイル時には検出されない実行時型エラーが発生し、アプリケーションが意図しない挙動をしたり、クラッシュしたりします。
Zodのようなスキーマバリデーションライブラリは、これらの外部データをアプリケーション内部に取り込む前に、定義したスキーマ(期待する型と構造)に沿っているかを**実行時に検証(ランタイムバリデーション)**します。これにより、型不一致による実行時エラーを未然に防ぎ、TypeScriptの型安全性を実行時まで拡張することが可能になります。
Zodの基本的な使い方
このセクションでは、Zodのインストールから基本的なスキーマ定義、データの検証、そしてTypeScriptの型推論との連携までを解説します。
環境構築とインストール
まず、ZodとTypeScriptをプロジェクトにインストールします。
npm install zod typescript
tsconfig.jsonで"strict": trueを設定することを推奨します。
// tsconfig.json
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"strict": true, // これをtrueに設定
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
スキーマ定義とTypeScript型推論
Zodは、JavaScriptオブジェクトと同じ感覚でスキーマを定義できます。z.object()でオブジェクトのスキーマを定義し、各プロパティの型やバリデーションルールを指定します。
最も重要なのは、定義したZodスキーマからTypeScriptの型を自動的に推論するz.infer<typeof Schema>です。これにより、型定義の重複をなくし、常にバリデーションロジックと型が同期することを保証します。
import { z } from "zod";
// ユーザーデータのスキーマ定義
const UserSchema = z.object({
id: z.number().int().positive(), // 整数で正の数
name: z.string().min(1, "名前は必須です"), // 1文字以上の文字列
email: z.string().email("無効なメールアドレス形式です"), // メールアドレス形式
age: z.number().min(18, "18歳以上である必要があります").optional(), // オプションで18歳以上の数値
});
// スキーマからTypeScriptの型を推論
type User = z.infer<typeof UserSchema>;
// User は { id: number; name: string; email: string; age?: number; } と推論される
データの検証: parse() と safeParse()
Zodでデータを検証するには、主にparse()とsafeParse()の2つのメソッドを使用します。
-
parse(data): データがスキーマに一致しない場合、ZodErrorをスローします。 -
safeParse(data): データがスキーマに一致するかどうかを{ success: boolean, data?: T, error?: ZodError }のような結果オブジェクトで返します。エラーをスローしないため、より柔軟なエラーハンドリングが可能です。
外部からの信頼できないデータにはsafeParse()、アプリケーション内部で既に型が保証されているデータにはparse()を使用するのがベストプラクティスです。
// 正常なデータ
const validUserData = {
id: 123,
name: "Taro Yamada",
email: "taro.yamada@example.com",
};
// 異常なデータ
const invalidUserData = {
id: "abc", // 型が異なる
name: "", // 最小文字数未満
email: "invalid-email", // メールアドレス形式ではない
age: 16, // 18歳未満
};
// parse() を使用した検証(エラー発生時は例外をスロー)
try {
const user: User = UserSchema.parse(validUserData);
console.log("Valid user:", user);
} catch (error) {
if (error instanceof z.ZodError) {
console.error("Validation error (parse):", error.flatten()); // エラーをフラット化して表示
} else {
console.error("Unknown error:", error);
}
}
// safeParse() を使用した検証(エラー発生時は結果オブジェクトを返す)
const result = UserSchema.safeParse(invalidUserData);
if (result.success) {
const user: User = result.data;
console.log("Valid user (safeParse):", user);
} else {
console.error("Validation error (safeParse):", result.error.flatten());
}
APIレスポンスの検証例
実際のアプリケーションでは、以下のようにAPIレスポンスの検証にZodを活用します。
import { z } from "zod";
const UserSchema = z.object({
id: z.number().int().positive(),
name: z.string().min(1),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
async function fetchUser(userId: number): Promise<User | null> {
// 実際にはfetch APIを使用し、JSONレスポンスを取得します
// const response = await fetch(`/api/users/${userId}`);
// const data = await response.json();
// モックデータを使用
const mockResponseData = userId === 1
? { id: 1, name: "John Doe", email: "john@example.com" }
: { id: "invalid", name: "", email: "bad" }; // 意図的に不正なデータ
const parsedResult = UserSchema.safeParse(mockResponseData);
if (!parsedResult.success) {
console.error(`API response validation failed for user ${userId}:`, parsedResult.error.flatten());
return null;
}
return parsedResult.data;
}
(async () => {
console.log("\n--- Fetching User 1 (Valid) ---");
const user1 = await fetchUser(1);
if (user1) console.log("Fetched user 1:", user1); // 型はUser
console.log("\n--- Fetching User 2 (Invalid) ---");
const user2 = await fetchUser(2);
if (user2) console.log("Fetched user 2:", user2); // nullが返る
})();
この例では、fetchUser関数がAPIから取得したデータをUserSchemaで検証し、型安全なUserオブジェクト、またはバリデーションエラーに応じてnullを返します。これにより、アプリケーションの残りの部分では、user1が常にUser型であることが保証され、TypeScript実行時の型エラーを防ぎます。
Zodで解決する3つの落とし穴とベストプラクティス
Zodは強力ですが、使い方を誤ると意図しない挙動やエラーにつながることがあります。ここでは、多くの開発者が遭遇しやすい3つの落とし穴と、それらを回避するためのベストプラクティスを解説します。
落とし穴1: TypeScriptの型とZodスキーマの不一致
ハマりどころ
TypeScriptの型定義とZodスキーマを別々に手動で定義すると、両者の間に不一致が生じるリスクがあります。TypeScriptはコンパイル時に型チェックを行いますが、実行時にはZodスキーマが実際のデータを検証するため、この不一致がTypeScript実行時の型エラーにつながります。
// 悪い例: 型とスキーマを別々に定義し、不一致のリスクがある
interface UserType {
id: number;
name: string;
}
const UserSchemaBad = z.object({
id: z.string(), // ここで型が異なる
name: z.string(),
});
// この時点ではTypeScriptはエラーを出さないが、実行時に id: number が期待される場所で id: string になるとエラーになる
const userData: UserType = { id: 123, name: "Taro" };
// UserSchemaBad.parse(userData); // 実行時に id が string ではないためエラー
回避策: z.infer<typeof Schema> で型を推論する
z.infer<typeof Schema> を使用して、Zodスキーマから直接TypeScriptの型を推論します。これにより、型定義の重複をなくし、常にスキーマと型が同期している状態を保てます。これがZodを使う上での最も重要なベストプラクティスです。
// 良い例: スキーマから型を推論する
const UserSchemaGood = z.object({
id: z.number(),
name: z.string(),
});
type UserGood = z.infer<typeof UserSchemaGood>; // UserGoodは { id: number; name: string; } となる
const userData: UserGood = { id: 123, name: "Taro" }; // TypeScriptの型チェックもOK
UserSchemaGood.parse(userData); // 実行時バリデーションもOK
常にz.inferで型を生成することで、Zodスキーマの変更がTypeScriptの型にも自動的に反映され、型定義とバリデーションロジックの間に齟齬が生じることを防ぎます。
落とし穴2: z.union() の順序による予期せぬバリデーション結果
ハマりどころ
z.union([SchemaA, SchemaB]) のように複数のスキーマのいずれかに一致することを期待する場合、その順序が重要になることがあります。特に、より一般的なスキーマ(例: プロパティが少ない、オプションが多い)を先に定義すると、Zodがその一般的なスキーマに一致すると判断し、後続のより具体的なスキーマを検証しないことがあります。
const SpecificSchema = z.object({ type: z.literal("A"), value: z.string() });
const GeneralSchema = z.object({ type: z.literal("B"), optionalValue: z.string().optional() });
// 悪い例: GeneralSchemaが先に評価されると、SpecificSchemaに到達しない可能性がある
// GeneralSchema は type: "B" を持つオブジェクトだが、optionalValue がなくてもマッチする
// もしデータが { type: "A", value: "hello" } の場合でも、
// GeneralSchemaの type が "B" でないためマッチしないが、
// もっと一般的なスキーマ(例: { type: z.string().optional(), ... })があった場合に問題になる。
// ここではよりわかりやすい Discriminated Union の文脈で解説する。
// 以下のケースでは、typeが"A"のデータがGeneralSchemaに先にマッチすることはないが、
// もしGeneralSchemaが { anyProperty: z.any().optional(), type: z.literal("B").optional() }
// のような非常に緩いスキーマだった場合、問題が発生する。
// より正確な例としては、識別子を持たないunionで発生しやすい。
// 識別子がある場合は `z.discriminatedUnion` を使うべき。
// 識別子がない場合の union の順序による問題は、
// 厳密な型チェックができないケースや、部分的なオブジェクトを受け入れるスキーマで発生しやすい。
const LooseSchema = z.object({ id: z.string().optional() });
const StrictSchema = z.object({ id: z.string(), name: z.string() });
// 悪い例: LooseSchemaが先にマッチしてしまい、StrictSchemaの検証が行われない可能性
const UnionSchemaBad = z.union([LooseSchema, StrictSchema]);
// `{ id: "1" }` は LooseSchema にマッチするが、StrictSchema にはマッチしない
// `{ id: "1", name: "test" }` も LooseSchema にマッチする
// 結果として、nameが必須であるStrictSchemaのルールが適用されない可能性がある
回避策: より具体的なスキーマを先に定義する、または z.discriminatedUnion() を使用する
z.union() を使用する際は、より具体的なスキーマを先に定義し、一般的なスキーマを後に配置するように順序を調整します。
また、オブジェクトの共用体で共通のプロパティ(識別子)を持つ場合は、z.discriminatedUnion()を使用すると、Zodが識別子の値に基づいて効率的にスキーマを選択するため、順序による問題を回避できます。
// 良い例1: より具体的なスキーマを先に定義する(識別子がない場合)
const StrictSchemaGood = z.object({ id: z.string(), name: z.string() });
const LooseSchemaGood = z.object({ id: z.string().optional() });
const UnionSchemaGood = z.union([StrictSchemaGood, LooseSchemaGood]);
// `{ id: "1", name: "test" }` は StrictSchemaGood にマッチ
// `{ id: "1" }` は StrictSchemaGood にマッチしないため、LooseSchemaGood にマッチ
// 良い例2: 識別子を持つ共用体には `z.discriminatedUnion()` を使用
const TextMessage = z.object({
type: z.literal("text"),
content: z.string(),
});
const ImageMessage = z.object({
type: z.literal("image"),
url: z.string().url(),
alt: z.string().optional(),
});
const MessageSchema = z.discriminatedUnion("type", [TextMessage, ImageMessage]);
type Message = z.infer<typeof MessageSchema>;
const msg1: Message = { type: "text", content: "Hello" };
const msg2: Message = { type: "image", url: "http://example.com/img.png" };
console.log(MessageSchema.parse(msg1));
console.log(MessageSchema.parse(msg2));
try {
MessageSchema.parse({ type: "image", content: "bad" }); // typeがimageなのにcontentがあるためエラー
} catch (error) {
if (error instanceof z.ZodError) {
console.error("Discriminated union error:", error.issues[0].message);
}
}
z.discriminatedUnionは、特定のプロパティ(識別子)の値によってスキーマを分岐させるため、順序に依存しない堅牢なバリデーションが可能です。
落とし穴3: APIからの部分的に不正なデータによる全体エラー
ハマりどころ
APIからリスト形式のデータ(例: 50個の製品リスト)を取得する際に、その中の1つの要素(例: 1つの製品の価格)が不正な形式だったとします。この場合、z.array(ProductSchema).parse(data) のように配列全体を一度に検証すると、たった1つの不正なデータのために配列全体のバリデーションが失敗し、アプリケーションがクラッシュしたり、ユーザーに「何かがおかしい」という一般的なエラーが表示されたりすることがあります。これはユーザーエクスペリエンスを著しく損ねます。
const ProductSchema = z.object({
id: z.number(),
name: z.string(),
price: z.number().positive(),
});
const apiResponse = [
{ id: 1, name: "Product A", price: 100 },
{ id: 2, name: "Product B", price: "invalid" }, // 不正なデータ
{ id: 3, name: "Product C", price: 200 },
];
// 悪い例: 全体が失敗する
const AllProductsSchema = z.array(ProductSchema);
const resultBad = AllProductsSchema.safeParse(apiResponse);
if (!resultBad.success) {
console.error("Entire array validation failed:", resultBad.error.flatten());
// この場合、Product A と Product C のデータも利用できない
}
回避策: 個々の要素を safeParse() で検証し、正常なデータを救済する
配列内の個々の要素に対して safeParse() を適用して、不正なデータがあっても正常なデータを救済できるようにします。これにより、部分的なエラーでもアプリケーション全体がクラッシュするのを防ぎ、より良いユーザーエクスペリエンスを提供できます。
type Product = z.infer<typeof ProductSchema>;
const validProducts: Product[] = [];
const errors: z.ZodIssue[] = [];
apiResponse.forEach((item, index) => {
const parsedItem = ProductSchema.safeParse(item);
if (parsedItem.success) {
validProducts.push(parsedItem.data);
} else {
// エラーに配列のインデックス情報を付加するとデバッグしやすい
errors.push(...parsedItem.error.issues.map(issue => ({ ...issue, path: [`[${index}]`, ...issue.path] })));
}
});
console.log("\nValid products:", validProducts); // Product A と Product C は利用可能
console.log("Errors for individual items:", errors); // Product B のエラーのみ表示
このアプローチにより、ユーザーは部分的にでもデータを閲覧・操作できる可能性があり、エラー発生時も具体的なエラー箇所を特定しやすくなります。
その他のZod活用術とベストプラクティス
ここでは、Zodをより効果的に使うための追加のテクニックと設計上の考慮点を紹介します。
z.coerce を使用した型変換
z.coerceは、文字列として渡されるが数値として扱う必要があるパラメータ(例:クエリパラメータのpage、pageSize、IDなど)の型変換を自動化し、TypeScriptの型安全性を維持します。
import { z } from "zod";
const IdSchema = z.coerce.number().int().positive("IDは正の整数である必要があります");
console.log("Coerced '123':", IdSchema.parse("123")); // 123 (number)
console.log("Coerced 456:", IdSchema.parse(456)); // 456 (number)
try {
IdSchema.parse("abc"); // エラーをスロー
} catch (error) {
if (error instanceof z.ZodError) {
console.error("Coercion error for 'abc':", error.issues[0].message);
}
}
カスタムエラーメッセージとグローバルエラーマップ
ユーザーフレンドリーなエラーメッセージはUX向上に不可欠です。Zodは、個々のバリデーションルールにカスタムメッセージを設定できるだけでなく、z.setErrorMap()でグローバルなエラーメッセージをカスタマイズすることも可能です。
import { ZodIssueCode, z } from "zod";
const LoginSchema = z.object({
email: z.string().email("メールアドレスの形式が正しくありません"),
password: z.string().min(8, { message: "パスワードは8文字以上である必要があります" }),
});
try {
LoginSchema.parse({ email: "test@example.com", password: "short" });
} catch (error: any) {
if (error instanceof z.ZodError) {
console.error("Login validation error (custom message):", error.issues[0].message);
}
}
// グローバルなエラーマップの設定例
z.setErrorMap((issue, ctx) => {
if (issue.code === ZodIssueCode.invalid_type) {
return { message: `入力値の型が不正です: ${issue.path.join('.')} は ${issue.expected} を期待しましたが ${issue.received} でした` };
}
if (issue.code === ZodIssueCode.too_small && issue.type === "string") {
return { message: `文字列は少なくとも ${issue.minimum} 文字必要です` };
}
return { message: ctx.defaultError }; // デフォルトのエラーメッセージを返す
});
const GlobalErrorSchema = z.object({
name: z.string().min(5),
age: z.number(),
});
try {
GlobalErrorSchema.parse({ name: "abc", age: "twenty" });
} catch (error: any) {
if (error instanceof z.ZodError) {
console.error("\nValidation error (global error map):", error.issues.map(i => i.message));
}
}
z.setErrorMapを使うことで、アプリケーション全体で一貫したエラーメッセージを提供できます。
設計上のトレードオフ
Zodの導入にはメリットが多いですが、いくつかのトレードオフも存在します。
- パフォーマンスオーバーヘッド: ランタイムバリデーションは、追加のコード実行を伴うため、わずかながらパフォーマンスオーバーヘッドが発生します。Zodは効率的に設計されていますが、ホットループ内でのスキーマ解析を避けるなど、パフォーマンスが重要なシナリオでは考慮が必要です。
- バンドルサイズ: Zodは8KB(minified + zipped)と比較的小さいですが、クライアントサイドのバンドルサイズが非常に重要なアプリケーションでは、Valibotのようなより軽量な代替案も検討する価値があります。
これらのトレードオフを理解した上で、プロジェクトの要件に合わせてZodを効果的に活用しましょう。
まとめ
この記事では、TypeScript開発者が直面するTypeScript実行時の型エラーの問題に対し、Zodを用いた解決策を解説しました。
重要なポイントは以下の3点です。
-
ZodスキーマからTypeScript型を推論する (
z.infer<typeof Schema>): 型定義とバリデーションロジックの同期を保ち、型不一致の落とし穴を回避します。 -
z.union()の順序とz.discriminatedUnion()の活用: 複雑な型定義での予期せぬバリデーション結果を防ぎます。特に識別子を持つ共用体にはz.discriminatedUnionが強力です。 -
部分的な不正データへの対処 (
safeParse()の個別適用): APIからのリストデータなど、一部が不正でもアプリケーション全体が停止しないよう、正常なデータを救済する柔軟なエラーハンドリングを実現します。
Zodを適切に活用することで、コンパイル時だけでなく実行時においてもアプリケーションの型安全性を確保し、堅牢で信頼性の高いシステムを構築できます。
Zodのより高度な機能や詳細については、Zodの公式ドキュメントも参照してください。