はじめに#
TypeScriptは静的型付け言語として、コンパイル時に型の整合性を保証します。しかし、APIレスポンスやフォーム入力など、外部から流入するデータは実行時まで内容が確定しません。このギャップを埋めるのが、TypeScriptファーストのバリデーションライブラリ「Zod」です。
Zodを使えば、スキーマ定義から自動的にTypeScript型を生成できます。これにより、バリデーションロジックと型定義を一元管理し、実行時の安全性と開発時の型補完を両立できます。
本記事では、Zodの基本構文から実践的な活用パターンまでを体系的に解説します。
この記事を読み終えると、以下のことができるようになります。
- Zodスキーマを定義し、TypeScript型を自動生成できる(
z.infer)
- APIレスポンスを型安全にバリデーションできる
- React Hook FormとZodを連携してフォームバリデーションを実装できる
- エラーメッセージをカスタマイズし、ユーザーフレンドリーなUIを構築できる
実行環境・前提条件#
前提知識#
動作確認環境#
| ツール |
バージョン |
| Node.js |
20.x以上 |
| TypeScript |
5.5以上 |
| Zod |
4.x |
| React |
18.x以上(フォーム連携時) |
| React Hook Form |
7.55以上(フォーム連携時) |
インストール#
React Hook Formと連携する場合は、追加でresolverをインストールします。
1
|
npm install react-hook-form @hookform/resolvers
|
期待される結果#
本記事のコードを実行すると、以下の動作を確認できます。
- スキーマからTypeScript型が自動生成される
- 不正なデータに対してバリデーションエラーが発生する
- 型安全なフォームバリデーションが動作する
Zodの基本 - スキーマ定義とパース#
スキーマとは何か#
Zodにおける「スキーマ」とは、データの構造と制約を宣言的に定義したオブジェクトです。スキーマを使ってデータを検証(パース)すると、型安全なデータが得られます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
import { z } from "zod";
// ユーザースキーマを定義
const UserSchema = z.object({
name: z.string(),
age: z.number(),
email: z.string().email(),
});
// スキーマに基づいてデータを検証
const result = UserSchema.parse({
name: "田中太郎",
age: 30,
email: "tanaka@example.com",
});
console.log(result);
// => { name: "田中太郎", age: 30, email: "tanaka@example.com" }
|
parseメソッドは、入力データがスキーマに適合する場合は検証済みデータを返し、適合しない場合はZodErrorをスローします。
プリミティブ型のスキーマ#
Zodは、TypeScriptのプリミティブ型に対応するスキーマを提供します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
import { z } from "zod";
// 基本的なプリミティブ型
const stringSchema = z.string();
const numberSchema = z.number();
const booleanSchema = z.boolean();
const bigintSchema = z.bigint();
// 特殊な型
const nullSchema = z.null();
const undefinedSchema = z.undefined();
const voidSchema = z.void(); // undefinedと等価
// 使用例
stringSchema.parse("hello"); // => "hello"
numberSchema.parse(42); // => 42
booleanSchema.parse(true); // => true
|
文字列スキーマの制約#
文字列に対して様々な制約を追加できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
import { z } from "zod";
// 長さの制約
const usernameSchema = z.string()
.min(3, "ユーザー名は3文字以上で入力してください")
.max(20, "ユーザー名は20文字以下で入力してください");
// 正規表現による検証
const phoneSchema = z.string()
.regex(/^0\d{9,10}$/, "有効な電話番号を入力してください");
// 組み込みフォーマット検証(Zod 4)
const emailSchema = z.email(); // メールアドレス
const urlSchema = z.url(); // URL
const uuidSchema = z.uuid(); // UUID
const datetimeSchema = z.iso.datetime(); // ISO 8601日時
// 文字列変換
const trimmedSchema = z.string().trim(); // 空白を除去
const lowercaseSchema = z.string().toLowerCase(); // 小文字に変換
const uppercaseSchema = z.string().toUpperCase(); // 大文字に変換
|
数値スキーマの制約#
数値にも範囲やステップなどの制約を設定できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
import { z } from "zod";
// 範囲の制約
const ageSchema = z.number()
.min(0, "年齢は0以上で入力してください")
.max(150, "年齢は150以下で入力してください");
// 整数の検証(Zod 4)
const intSchema = z.int();
// 正の数・負の数
const positiveSchema = z.number().positive();
const negativeSchema = z.number().negative();
const nonNegativeSchema = z.number().nonnegative();
// 倍数
const multiplesOf5 = z.number().multipleOf(5);
// 使用例
ageSchema.parse(25); // => 25
intSchema.parse(10); // => 10
intSchema.parse(10.5); // => ZodError
|
TypeScript型の自動生成 - z.inferの活用#
z.inferで型を抽出する#
Zodの最大の強みは、スキーマ定義からTypeScript型を自動生成できることです。これにより、バリデーションロジックと型定義の二重管理を回避できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
import { z } from "zod";
// スキーマを定義
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
age: z.number().optional(),
role: z.enum(["admin", "user", "guest"]),
});
// スキーマから型を生成
type User = z.infer<typeof UserSchema>;
// 生成される型は以下と等価
// type User = {
// id: number;
// name: string;
// email: string;
// age?: number | undefined;
// role: "admin" | "user" | "guest";
// };
// 型安全に使用可能
const user: User = {
id: 1,
name: "田中太郎",
email: "tanaka@example.com",
role: "admin",
};
|
入力型と出力型の違い#
transformやdefaultを使用すると、入力と出力の型が異なる場合があります。z.inputとz.outputで区別できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
import { z } from "zod";
const TransformSchema = z.object({
createdAt: z.string().transform((val) => new Date(val)),
count: z.number().default(0),
});
// 入力型(パース前)
type TransformInput = z.input<typeof TransformSchema>;
// {
// createdAt: string;
// count?: number | undefined;
// }
// 出力型(パース後)- z.inferと等価
type TransformOutput = z.output<typeof TransformSchema>;
// {
// createdAt: Date;
// count: number;
// }
// 使用例
const result = TransformSchema.parse({
createdAt: "2026-01-01T00:00:00Z",
// countは省略可能、デフォルト値が適用される
});
console.log(result.createdAt instanceof Date); // => true
console.log(result.count); // => 0
|
型の再利用と合成#
スキーマを合成して複雑な型を構築できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
import { z } from "zod";
// 基本スキーマ
const AddressSchema = z.object({
postalCode: z.string().regex(/^\d{3}-\d{4}$/),
prefecture: z.string(),
city: z.string(),
street: z.string(),
});
// 基本スキーマを拡張
const UserSchema = z.object({
id: z.number(),
name: z.string(),
address: AddressSchema, // ネスト
});
// extendで拡張
const AdminSchema = UserSchema.extend({
permissions: z.array(z.string()),
department: z.string(),
});
// pickで一部を抽出
const UserSummarySchema = UserSchema.pick({
id: true,
name: true,
});
// omitで一部を除外
const UserWithoutAddressSchema = UserSchema.omit({
address: true,
});
// 型を生成
type User = z.infer<typeof UserSchema>;
type Admin = z.infer<typeof AdminSchema>;
type UserSummary = z.infer<typeof UserSummarySchema>;
|
オブジェクトスキーマの詳細#
オブジェクトの定義#
z.objectでオブジェクト型のスキーマを定義します。デフォルトでは、定義されていないキーは除去されます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
import { z } from "zod";
const PersonSchema = z.object({
name: z.string(),
age: z.number(),
});
// 未定義のキーは除去される
const result = PersonSchema.parse({
name: "田中",
age: 30,
extraKey: "削除される",
});
console.log(result);
// => { name: "田中", age: 30 }
// extraKeyは含まれない
|
strictとlooseモード#
未定義キーの扱いを変更できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
import { z } from "zod";
// strict: 未定義キーがあるとエラー
const StrictSchema = z.strictObject({
name: z.string(),
});
StrictSchema.parse({ name: "田中", extra: "エラー" });
// => ZodError: Unrecognized key(s) in object: 'extra'
// loose: 未定義キーをそのまま通す
const LooseSchema = z.looseObject({
name: z.string(),
});
const looseResult = LooseSchema.parse({ name: "田中", extra: "保持" });
console.log(looseResult);
// => { name: "田中", extra: "保持" }
|
オプショナルとデフォルト値#
プロパティをオプショナルにしたり、デフォルト値を設定できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
import { z } from "zod";
const SettingsSchema = z.object({
// オプショナル(undefinedを許容)
theme: z.string().optional(),
// nullを許容
nickname: z.string().nullable(),
// nullとundefinedを両方許容
bio: z.string().nullish(),
// デフォルト値
language: z.string().default("ja"),
// デフォルト値(関数で動的生成)
createdAt: z.date().default(() => new Date()),
});
type Settings = z.infer<typeof SettingsSchema>;
// {
// theme?: string | undefined;
// nickname: string | null;
// bio?: string | null | undefined;
// language: string;
// createdAt: Date;
// }
const result = SettingsSchema.parse({
nickname: null,
});
console.log(result.language); // => "ja"
console.log(result.createdAt); // => 現在の日時
|
partialとrequired#
既存スキーマのオプショナル状態を変更できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
// 全プロパティをオプショナルに
const PartialUserSchema = UserSchema.partial();
type PartialUser = z.infer<typeof PartialUserSchema>;
// {
// id?: number | undefined;
// name?: string | undefined;
// email?: string | undefined;
// }
// 特定のプロパティのみオプショナルに
const UserWithOptionalEmailSchema = UserSchema.partial({
email: true,
});
// 全プロパティを必須に
const RequiredUserSchema = PartialUserSchema.required();
|
配列・列挙型・ユニオン#
配列スキーマ#
配列の要素に対するスキーマを定義できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
import { z } from "zod";
// 文字列の配列
const StringArraySchema = z.array(z.string());
// オブジェクトの配列
const UsersSchema = z.array(z.object({
id: z.number(),
name: z.string(),
}));
// 配列の制約
const LimitedArraySchema = z.array(z.number())
.min(1, "最低1つの要素が必要です")
.max(10, "最大10個までです")
.nonempty("空の配列は許可されていません");
// 使用例
StringArraySchema.parse(["a", "b", "c"]); // => ["a", "b", "c"]
LimitedArraySchema.parse([1, 2, 3]); // => [1, 2, 3]
LimitedArraySchema.parse([]); // => ZodError
|
タプルスキーマ#
固定長配列で各インデックスに異なる型を指定できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
import { z } from "zod";
// 固定長タプル
const CoordinateSchema = z.tuple([z.number(), z.number()]);
type Coordinate = z.infer<typeof CoordinateSchema>;
// [number, number]
CoordinateSchema.parse([35.6762, 139.6503]); // => [35.6762, 139.6503]
// 可変長タプル(rest要素)
const ArgsSchema = z.tuple([z.string()], z.number());
type Args = z.infer<typeof ArgsSchema>;
// [string, ...number[]]
ArgsSchema.parse(["sum", 1, 2, 3]); // => ["sum", 1, 2, 3]
|
列挙型(enum)#
固定された値の集合を定義します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
import { z } from "zod";
// Zod enum
const StatusSchema = z.enum(["pending", "approved", "rejected"]);
type Status = z.infer<typeof StatusSchema>;
// "pending" | "approved" | "rejected"
StatusSchema.parse("pending"); // => "pending"
StatusSchema.parse("invalid"); // => ZodError
// enum値へのアクセス
StatusSchema.enum.pending; // => "pending"
StatusSchema.options; // => ["pending", "approved", "rejected"]
// TypeScript enumとの連携(Zod 4)
enum Role {
Admin = "admin",
User = "user",
Guest = "guest",
}
const RoleSchema = z.enum(Role);
RoleSchema.parse(Role.Admin); // => "admin"
RoleSchema.parse("admin"); // => "admin"
|
ユニオン型#
複数の型のいずれかを許容します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
import { z } from "zod";
// 基本的なユニオン
const StringOrNumberSchema = z.union([z.string(), z.number()]);
type StringOrNumber = z.infer<typeof StringOrNumberSchema>;
// string | number
StringOrNumberSchema.parse("hello"); // => "hello"
StringOrNumberSchema.parse(42); // => 42
StringOrNumberSchema.parse(true); // => ZodError
// 判別可能なユニオン(discriminated union)
const ResultSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("success"),
data: z.string(),
}),
z.object({
status: z.literal("error"),
message: z.string(),
code: z.number(),
}),
]);
type Result = z.infer<typeof ResultSchema>;
// 使用例
const success = ResultSchema.parse({
status: "success",
data: "処理が完了しました",
});
const error = ResultSchema.parse({
status: "error",
message: "エラーが発生しました",
code: 500,
});
|
判別可能なユニオンは、共通の判別キー(上記ではstatus)に基づいて効率的にパースを行うため、通常のユニオンよりもパフォーマンスが優れています。
APIレスポンスのバリデーション#
fetchとZodの組み合わせ#
外部APIから取得したデータをZodでバリデーションすることで、型安全性を実行時にも保証できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
import { z } from "zod";
// APIレスポンスのスキーマ定義
const UserResponseSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
createdAt: z.string().transform((val) => new Date(val)),
});
const UsersResponseSchema = z.array(UserResponseSchema);
type User = z.infer<typeof UserResponseSchema>;
// データ取得関数
async function fetchUsers(): Promise<User[]> {
const response = await fetch("https://api.example.com/users");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
// バリデーション実行
const result = UsersResponseSchema.safeParse(json);
if (!result.success) {
console.error("バリデーションエラー:", result.error.issues);
throw new Error("APIレスポンスの形式が不正です");
}
return result.data;
}
// 使用例
async function main() {
try {
const users = await fetchUsers();
// usersはUser[]型として型安全に扱える
users.forEach((user) => {
console.log(`${user.name}: ${user.email}`);
console.log(`登録日: ${user.createdAt.toLocaleDateString()}`);
});
} catch (error) {
console.error("ユーザー取得に失敗しました:", error);
}
}
|
汎用的なAPI関数の作成#
型パラメータを活用して、任意のスキーマに対応するfetch関数を作成できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
import { z, ZodSchema } from "zod";
// 汎用的なfetch関数
async function fetchWithValidation<T extends ZodSchema>(
url: string,
schema: T
): Promise<z.infer<T>> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
return schema.parse(json);
}
// 使用例
const PostSchema = z.object({
id: z.number(),
title: z.string(),
body: z.string(),
userId: z.number(),
});
const CommentSchema = z.object({
id: z.number(),
postId: z.number(),
name: z.string(),
email: z.string().email(),
body: z.string(),
});
async function main() {
// 型が自動的に推論される
const post = await fetchWithValidation(
"https://jsonplaceholder.typicode.com/posts/1",
PostSchema
);
// post: { id: number; title: string; body: string; userId: number }
const comments = await fetchWithValidation(
"https://jsonplaceholder.typicode.com/posts/1/comments",
z.array(CommentSchema)
);
// comments: Array<{ id: number; postId: number; ... }>
}
|
ネストされたレスポンスの処理#
実際のAPIは複雑なネスト構造を持つことが多いため、スキーマを階層的に定義します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
import { z } from "zod";
// ページネーション情報
const PaginationSchema = z.object({
currentPage: z.number(),
totalPages: z.number(),
totalItems: z.number(),
itemsPerPage: z.number(),
});
// 個別のアイテム
const ProductSchema = z.object({
id: z.string().uuid(),
name: z.string(),
price: z.number().positive(),
category: z.object({
id: z.number(),
name: z.string(),
}),
tags: z.array(z.string()),
createdAt: z.string().datetime(),
});
// APIレスポンス全体
const ProductListResponseSchema = z.object({
success: z.literal(true),
data: z.object({
products: z.array(ProductSchema),
pagination: PaginationSchema,
}),
});
// エラーレスポンス
const ErrorResponseSchema = z.object({
success: z.literal(false),
error: z.object({
code: z.string(),
message: z.string(),
details: z.array(z.string()).optional(),
}),
});
// 成功・エラー両対応
const ApiResponseSchema = z.discriminatedUnion("success", [
ProductListResponseSchema,
ErrorResponseSchema,
]);
type ApiResponse = z.infer<typeof ApiResponseSchema>;
|
zodResolverの設定#
React Hook FormとZodを連携するには、@hookform/resolversパッケージを使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
|
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// フォームスキーマを定義
const ContactFormSchema = z.object({
name: z.string()
.min(1, "お名前は必須です")
.max(50, "お名前は50文字以内で入力してください"),
email: z.string()
.min(1, "メールアドレスは必須です")
.email("有効なメールアドレスを入力してください"),
message: z.string()
.min(10, "メッセージは10文字以上で入力してください")
.max(1000, "メッセージは1000文字以内で入力してください"),
});
// スキーマから型を生成
type ContactFormData = z.infer<typeof ContactFormSchema>;
function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ContactFormData>({
resolver: zodResolver(ContactFormSchema),
});
const onSubmit = async (data: ContactFormData) => {
// dataは型安全
console.log("送信データ:", data);
await sendContactForm(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="name">お名前</label>
<input id="name" {...register("name")} />
{errors.name && (
<span className="error">{errors.name.message}</span>
)}
</div>
<div>
<label htmlFor="email">メールアドレス</label>
<input id="email" type="email" {...register("email")} />
{errors.email && (
<span className="error">{errors.email.message}</span>
)}
</div>
<div>
<label htmlFor="message">メッセージ</label>
<textarea id="message" {...register("message")} />
{errors.message && (
<span className="error">{errors.message.message}</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "送信中..." : "送信"}
</button>
</form>
);
}
|
条件付きバリデーション#
refineを使って、複数フィールドを跨ぐカスタムバリデーションを実装できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const PasswordFormSchema = z.object({
password: z.string()
.min(8, "パスワードは8文字以上で入力してください")
.regex(
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
"パスワードは大文字・小文字・数字を含める必要があります"
),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "パスワードが一致しません",
path: ["confirmPassword"], // エラーを表示するフィールド
});
type PasswordFormData = z.infer<typeof PasswordFormSchema>;
function PasswordForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<PasswordFormData>({
resolver: zodResolver(PasswordFormSchema),
});
const onSubmit = (data: PasswordFormData) => {
console.log("パスワード設定:", data.password);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="password">パスワード</label>
<input
id="password"
type="password"
{...register("password")}
/>
{errors.password && (
<span className="error">{errors.password.message}</span>
)}
</div>
<div>
<label htmlFor="confirmPassword">パスワード(確認)</label>
<input
id="confirmPassword"
type="password"
{...register("confirmPassword")}
/>
{errors.confirmPassword && (
<span className="error">{errors.confirmPassword.message}</span>
)}
</div>
<button type="submit">設定</button>
</form>
);
}
|
動的フォームへの対応#
配列フィールドを持つ動的フォームでもZodを活用できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
|
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const OrderFormSchema = z.object({
customerName: z.string().min(1, "お名前は必須です"),
items: z.array(z.object({
productId: z.string().min(1, "商品を選択してください"),
quantity: z.number()
.min(1, "数量は1以上で入力してください")
.max(99, "数量は99以下で入力してください"),
})).min(1, "最低1つの商品を追加してください"),
});
type OrderFormData = z.infer<typeof OrderFormSchema>;
function OrderForm() {
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<OrderFormData>({
resolver: zodResolver(OrderFormSchema),
defaultValues: {
customerName: "",
items: [{ productId: "", quantity: 1 }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "items",
});
const onSubmit = (data: OrderFormData) => {
console.log("注文データ:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="customerName">お名前</label>
<input id="customerName" {...register("customerName")} />
{errors.customerName && (
<span className="error">{errors.customerName.message}</span>
)}
</div>
<div>
<h3>注文商品</h3>
{fields.map((field, index) => (
<div key={field.id}>
<select {...register(`items.${index}.productId`)}>
<option value="">選択してください</option>
<option value="prod-1">商品A</option>
<option value="prod-2">商品B</option>
<option value="prod-3">商品C</option>
</select>
{errors.items?.[index]?.productId && (
<span className="error">
{errors.items[index]?.productId?.message}
</span>
)}
<input
type="number"
{...register(`items.${index}.quantity`, {
valueAsNumber: true,
})}
/>
{errors.items?.[index]?.quantity && (
<span className="error">
{errors.items[index]?.quantity?.message}
</span>
)}
<button type="button" onClick={() => remove(index)}>
削除
</button>
</div>
))}
{errors.items?.root && (
<span className="error">{errors.items.root.message}</span>
)}
<button
type="button"
onClick={() => append({ productId: "", quantity: 1 })}
>
商品を追加
</button>
</div>
<button type="submit">注文する</button>
</form>
);
}
|
エラーメッセージのカスタマイズ#
基本的なエラーメッセージ#
各バリデーションメソッドに直接メッセージを指定できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
import { z } from "zod";
const UserSchema = z.object({
username: z.string({
required_error: "ユーザー名は必須です",
invalid_type_error: "ユーザー名は文字列で入力してください",
})
.min(3, "ユーザー名は3文字以上で入力してください")
.max(20, "ユーザー名は20文字以下で入力してください")
.regex(/^[a-zA-Z0-9_]+$/, "ユーザー名は英数字とアンダースコアのみ使用できます"),
age: z.number({
required_error: "年齢は必須です",
invalid_type_error: "年齢は数値で入力してください",
})
.int("年齢は整数で入力してください")
.min(0, "年齢は0以上で入力してください")
.max(150, "年齢は150以下で入力してください"),
});
|
動的エラーメッセージ#
Zod 4では、関数を使って動的にエラーメッセージを生成できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
import { z } from "zod";
const ProductSchema = z.object({
name: z.string().min(1, {
message: "商品名は必須です",
}),
price: z.number().min(0, (issue) => ({
message: `価格は0以上で入力してください(入力値: ${issue.input})`,
})),
quantity: z.number().max(100, (issue) => ({
message: `在庫数は100以下にしてください(現在: ${issue.input})`,
})),
});
|
エラーのフォーマット#
ZodErrorからエラー情報を取得する方法は複数あります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
import { z } from "zod";
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().positive(),
});
const result = schema.safeParse({
name: "",
email: "invalid-email",
age: -5,
});
if (!result.success) {
// issues配列:全エラーの詳細
console.log(result.error.issues);
// [
// { code: "too_small", path: ["name"], message: "..." },
// { code: "invalid_string", path: ["email"], message: "..." },
// { code: "too_small", path: ["age"], message: "..." }
// ]
// format():ネスト構造でアクセスしやすい形式
const formatted = result.error.format();
console.log(formatted.name?._errors); // ["..."]
console.log(formatted.email?._errors); // ["..."]
// flatten():フラットな構造
const flattened = result.error.flatten();
console.log(flattened.fieldErrors);
// { name: ["..."], email: ["..."], age: ["..."] }
}
|
カスタムバリデーションとrefine#
refineを使ってカスタムバリデーションを追加できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
|
import { z } from "zod";
// 単純なrefine
const PasswordSchema = z.string()
.min(8)
.refine(
(password) => /[A-Z]/.test(password),
{ message: "大文字を含める必要があります" }
)
.refine(
(password) => /[0-9]/.test(password),
{ message: "数字を含める必要があります" }
)
.refine(
(password) => /[!@#$%^&*]/.test(password),
{ message: "特殊文字を含める必要があります" }
);
// 非同期バリデーション
const UniqueEmailSchema = z.string().email().refine(
async (email) => {
const exists = await checkEmailExists(email);
return !exists;
},
{ message: "このメールアドレスは既に使用されています" }
);
// superRefineで複数のエラーを追加
const AdvancedPasswordSchema = z.string().superRefine((password, ctx) => {
if (password.length < 8) {
ctx.addIssue({
code: z.ZodIssueCode.too_small,
minimum: 8,
type: "string",
inclusive: true,
message: "パスワードは8文字以上必要です",
});
}
if (!/[A-Z]/.test(password)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "大文字を含める必要があります",
});
}
if (!/[0-9]/.test(password)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "数字を含める必要があります",
});
}
});
|
実践的なユースケース#
環境変数のバリデーション#
アプリケーション起動時に環境変数を検証することで、設定ミスを早期に検出できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
import { z } from "zod";
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(32),
ENABLE_DEBUG: z.coerce.boolean().default(false),
});
// 起動時にバリデーション
function validateEnv() {
const result = EnvSchema.safeParse(process.env);
if (!result.success) {
console.error("環境変数の設定エラー:");
result.error.issues.forEach((issue) => {
console.error(` ${issue.path.join(".")}: ${issue.message}`);
});
process.exit(1);
}
return result.data;
}
export const env = validateEnv();
// 以降、env.DATABASE_URLなど型安全にアクセス可能
|
フォームとAPIの型共有#
クライアントとサーバーで同じスキーマを共有することで、一貫性のあるバリデーションを実現できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
// shared/schemas/user.ts
import { z } from "zod";
export const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
password: z.string().min(8),
role: z.enum(["admin", "user"]).default("user"),
});
export const UpdateUserSchema = CreateUserSchema
.omit({ password: true })
.partial();
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserSchema>;
// client/components/UserForm.tsx
import { CreateUserSchema, CreateUserInput } from "@shared/schemas/user";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
function UserForm() {
const { register, handleSubmit } = useForm<CreateUserInput>({
resolver: zodResolver(CreateUserSchema),
});
// ...
}
// server/routes/users.ts
import { CreateUserSchema } from "@shared/schemas/user";
app.post("/users", async (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: "Validation failed",
details: result.error.flatten().fieldErrors,
});
}
const user = await createUser(result.data);
res.json(user);
});
|
よくあるパターンとベストプラクティス#
parseとsafeParseの使い分け#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
|
import { z } from "zod";
const schema = z.string().email();
// parse: エラー時に例外をスロー
// - try/catchでハンドリングが必要
// - 確実に成功することが分かっている場合に使用
try {
const email = schema.parse(input);
// emailは必ずstring型
} catch (error) {
if (error instanceof z.ZodError) {
console.error(error.issues);
}
}
// safeParse: 結果オブジェクトを返す
// - 例外を投げない
// - 条件分岐でエラーハンドリング
const result = schema.safeParse(input);
if (result.success) {
// result.data: string
console.log(result.data);
} else {
// result.error: ZodError
console.error(result.error.issues);
}
|
フォームバリデーションやAPIレスポンス検証など、エラーが頻繁に発生しうる場面ではsafeParseが適しています。一方、環境変数の検証など、エラー時にアプリケーションを停止すべき場面ではparseが適切です。
再利用可能なカスタムスキーマ#
よく使うバリデーションパターンを関数として抽出できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
import { z } from "zod";
// 日本の電話番号
export const japanesePhoneNumber = () =>
z.string().regex(
/^0\d{9,10}$/,
"有効な電話番号を入力してください(例: 0312345678)"
);
// 日本の郵便番号
export const japanesePostalCode = () =>
z.string().regex(
/^\d{3}-\d{4}$/,
"郵便番号はXXX-XXXXの形式で入力してください"
);
// 日本語を含む名前
export const japaneseName = () =>
z.string()
.min(1, "名前は必須です")
.max(50, "名前は50文字以内で入力してください")
.regex(
/^[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\u3400-\u4DBFa-zA-Z\s]+$/,
"名前に使用できない文字が含まれています"
);
// 使用例
const AddressSchema = z.object({
name: japaneseName(),
phone: japanesePhoneNumber(),
postalCode: japanesePostalCode(),
address: z.string().min(1),
});
|
まとめ#
本記事では、ZodによるTypeScript型安全バリデーションの実装方法を解説しました。
Zodを導入することで得られる主なメリットは以下のとおりです。
- 型定義の一元管理: スキーマから
z.inferで型を生成し、バリデーションロジックと型定義の二重管理を解消
- 実行時の安全性: APIレスポンスやフォーム入力など、外部データを型安全に検証
- 開発体験の向上: 自動補完やエラー検出が効くため、開発効率が向上
- React Hook Formとの親和性:
zodResolverで宣言的なフォームバリデーションを実現
TypeScriptの静的型付けと、Zodの実行時バリデーションを組み合わせることで、コンパイル時と実行時の両面からアプリケーションの堅牢性を高められます。
参考リンク#