1
1

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 を使いこなす ─ ランタイム型バリデーション完全ガイド

1
Posted at

TypeScript で Zod を使いこなす ─ ランタイム型バリデーション完全ガイド

TypeScript を使っていても、「外から来たデータ」の型は保証できません。

APIのレスポンス、フォームの入力値、JSONファイルの中身... これらは実行時に初めてわかるデータで、TypeScript の型システムはコンパイル時にしか機能しません。

const response = await fetch("/api/user");
const user = await response.json() as User; // ← この as は嘘をついているかもしれない

Zod はこの問題を解決するライブラリです。スキーマを定義すると、TypeScript の型定義とランタイムのバリデーションを同時に得られます。

この記事で学べること:

  • Zod の基本的なスキーマ定義
  • TypeScript 型との連携(z.infer
  • よく使うスキーマパターン(オブジェクト・配列・ユニオン・変換)
  • APIレスポンスのバリデーション実践例
  • フォームバリデーションへの応用

検証環境: TypeScript 5+, Zod 3.x


インストールと基本

npm install zod
import { z } from "zod";

// スキーマを定義
const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

// TypeScript 型を自動生成
type User = z.infer<typeof UserSchema>;
// → { id: number; name: string; email: string }

// バリデーション
const result = UserSchema.safeParse({
  id: 1,
  name: "Alice",
  email: "alice@example.com",
});

if (result.success) {
  console.log(result.data); // User 型として使える
} else {
  console.log(result.error.issues); // エラー詳細
}

safeParse は例外を投げずに結果オブジェクトを返します。parse は失敗時に例外を投げます。基本的には safeParse を使う方が安全です。


プリミティブ型のスキーマ

import { z } from "zod";

// 基本型
z.string()
z.number()
z.boolean()
z.date()
z.undefined()
z.null()
z.any()
z.unknown()

// 制約付き
z.string().min(1)             // 最小1文字
z.string().max(100)           // 最大100文字
z.string().email()            // メールアドレス形式
z.string().url()              // URL形式
z.string().uuid()             // UUID形式
z.string().regex(/^[A-Z]+$/)  // 正規表現

z.number().min(0)             // 0以上
z.number().max(100)           // 100以下
z.number().int()              // 整数のみ
z.number().positive()         // 正の数
z.number().nonnegative()      // 0以上

// Literal(特定の値のみ)
z.literal("active")
z.literal(42)
z.literal(true)

オブジェクト・配列・ユニオン

オブジェクト

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  zipCode: z.string().regex(/^\d{3}-\d{4}$/),
  country: z.string().default("JP"), // デフォルト値
});

const UserSchema = z.object({
  id: z.number(),
  name: z.string().min(1).max(50),
  email: z.string().email(),
  age: z.number().int().min(0).max(150).optional(), // オプショナル
  address: AddressSchema.optional(),
});

type User = z.infer<typeof UserSchema>;

配列

const TagsSchema = z.array(z.string()).min(1).max(10);
const NumberListSchema = z.array(z.number().positive());

// タプル(固定長配列)
const CoordinateSchema = z.tuple([z.number(), z.number()]);
type Coordinate = z.infer<typeof CoordinateSchema>; // [number, number]

ユニオン

// 文字列ユニオン
const StatusSchema = z.union([
  z.literal("pending"),
  z.literal("running"),
  z.literal("done"),
  z.literal("error"),
]);
// または enum で書ける
const StatusSchema2 = z.enum(["pending", "running", "done", "error"]);

type Status = z.infer<typeof StatusSchema>; // "pending" | "running" | "done" | "error"

// 型ユニオン(discriminated union)
const ResponseSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("success"),
    data: z.array(z.unknown()),
  }),
  z.object({
    type: z.literal("error"),
    message: z.string(),
    code: z.number(),
  }),
]);

type Response = z.infer<typeof ResponseSchema>;
// { type: "success"; data: unknown[] } | { type: "error"; message: string; code: number }

変換(transform)

バリデーション後にデータを変換できます。

// 文字列 → 数値に変換
const NumberFromStringSchema = z.string().transform(val => parseInt(val, 10));

// 日付文字列 → Date オブジェクトに変換
const DateFromStringSchema = z.string().datetime().transform(val => new Date(val));

// オブジェクトの変換
const ApiUserSchema = z.object({
  user_id: z.number(),   // スネークケース
  full_name: z.string(),
  email_address: z.string().email(),
}).transform(data => ({
  userId: data.user_id,   // キャメルケースに変換
  fullName: data.full_name,
  email: data.email_address,
}));

type User = z.infer<typeof ApiUserSchema>;
// { userId: number; fullName: string; email: string }

const result = ApiUserSchema.parse({
  user_id: 1,
  full_name: "Alice Smith",
  email_address: "alice@example.com",
});
console.log(result.userId); // 1(キャメルケースに変換済み)

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

import { z } from "zod";

// スキーマ定義
const PostSchema = z.object({
  id: z.number(),
  title: z.string().min(1),
  body: z.string(),
  userId: z.number(),
  tags: z.array(z.string()).default([]),
  createdAt: z.string().datetime().optional(),
});

const PostsResponseSchema = z.object({
  posts: z.array(PostSchema),
  total: z.number(),
  page: z.number(),
  limit: z.number(),
});

type Post = z.infer<typeof PostSchema>;
type PostsResponse = z.infer<typeof PostsResponseSchema>;

// フェッチ関数
async function fetchPosts(page: number = 1): Promise<PostsResponse> {
  const response = await fetch(`/api/posts?page=${page}`);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  const raw = await response.json();

  // バリデーション
  const result = PostsResponseSchema.safeParse(raw);
  if (!result.success) {
    console.error("APIレスポンスの形式が不正:", result.error.issues);
    throw new Error("APIレスポンスの形式が不正です");
  }

  return result.data; // 型安全なデータ
}

// 使い方
const { posts, total } = await fetchPosts(1);
posts.forEach(post => {
  console.log(post.title); // string として確定している
});

実践: フォームバリデーション

React Hook Form と組み合わせる例(@hookform/resolvers が必要)。

import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

const ContactFormSchema = z.object({
  name: z.string()
    .min(1, "名前を入力してください")
    .max(50, "名前は50文字以内で入力してください"),
  email: z.string()
    .min(1, "メールアドレスを入力してください")
    .email("有効なメールアドレスを入力してください"),
  subject: z.enum(["inquiry", "support", "feedback"], {
    errorMap: () => ({ message: "件名を選択してください" }),
  }),
  message: z.string()
    .min(10, "メッセージは10文字以上入力してください")
    .max(1000, "メッセージは1000文字以内で入力してください"),
  agreeToTerms: z.boolean()
    .refine(val => val === true, "利用規約に同意してください"),
});

type ContactFormData = z.infer<typeof ContactFormSchema>;

export default function ContactForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<ContactFormData>({
    resolver: zodResolver(ContactFormSchema),
  });

  const onSubmit = async (data: ContactFormData) => {
    // data は ContactFormData 型として保証されている
    await fetch("/api/contact", {
      method: "POST",
      body: JSON.stringify(data),
    });
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <input {...register("name")} placeholder="名前" />
        {errors.name && <span>{errors.name.message}</span>}
      </div>
      <div>
        <input {...register("email")} placeholder="メールアドレス" />
        {errors.email && <span>{errors.email.message}</span>}
      </div>
      <div>
        <textarea {...register("message")} placeholder="メッセージ" />
        {errors.message && <span>{errors.message.message}</span>}
      </div>
      <button type="submit" disabled={isSubmitting}>送信</button>
    </form>
  );
}

refine と superRefine ─ カスタムバリデーション

// パスワード確認フィールドの例
const PasswordFormSchema = z.object({
  password: z.string().min(8, "パスワードは8文字以上"),
  confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
  message: "パスワードが一致しません",
  path: ["confirmPassword"], // エラーをつけるフィールド
});

// 複数のカスタムバリデーション
const ScheduleSchema = z.object({
  startDate: z.date(),
  endDate: z.date(),
}).superRefine((data, ctx) => {
  if (data.endDate <= data.startDate) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "終了日は開始日より後に設定してください",
      path: ["endDate"],
    });
  }
});

まとめ

Zod を使う理由をひとことで言うと、 「TypeScript の型とランタイムのバリデーションを1つのスキーマで管理できる」 からです。

// スキーマ1つで型定義とバリデーションが揃う
const UserSchema = z.object({ ... });
type User = z.infer<typeof UserSchema>; // 型定義
const validated = UserSchema.safeParse(data); // バリデーション

外部からデータが入ってくる場所(APIレスポンス・フォーム・設定ファイル)では、Zod でスキーマを定義しておくと、型安全性と実行時の安全性を両立できます。as でのキャストに頼る機会がぐっと減ります。

1
1
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
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?