はじめに

TypeScriptは静的型付け言語として、コンパイル時に型の整合性を保証します。しかし、APIレスポンスやフォーム入力など、外部から流入するデータは実行時まで内容が確定しません。このギャップを埋めるのが、TypeScriptファーストのバリデーションライブラリ「Zod」です。

Zodを使えば、スキーマ定義から自動的にTypeScript型を生成できます。これにより、バリデーションロジックと型定義を一元管理し、実行時の安全性と開発時の型補完を両立できます。

本記事では、Zodの基本構文から実践的な活用パターンまでを体系的に解説します。

この記事を読み終えると、以下のことができるようになります。

  • Zodスキーマを定義し、TypeScript型を自動生成できる(z.infer
  • APIレスポンスを型安全にバリデーションできる
  • React Hook FormとZodを連携してフォームバリデーションを実装できる
  • エラーメッセージをカスタマイズし、ユーザーフレンドリーなUIを構築できる

実行環境・前提条件

前提知識

  • TypeScriptの基本的な型注釈(基本型入門を参照)
  • オブジェクト型とinterfaceの使い方(typeとinterfaceの違いを参照)
  • Reactの基本(フォーム連携セクションで使用)

動作確認環境

ツール バージョン
Node.js 20.x以上
TypeScript 5.5以上
Zod 4.x
React 18.x以上(フォーム連携時)
React Hook Form 7.55以上(フォーム連携時)

インストール

1
npm install zod

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.inputz.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>;

React Hook Formとの連携

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の実行時バリデーションを組み合わせることで、コンパイル時と実行時の両面からアプリケーションの堅牢性を高められます。

参考リンク