Next.jsの境界をZodで検証する

Zod 4 のスキーマを、リクエスト、React Hook Form、Server Actions の検証に活用する方法をまとめます。

Published
2024年1月11日
Read in English

?limit= のように空のクエリが届くと、Number("")0 を返します。1件も表示されない一覧ページができあがり、原因はURLの一文字です。

URLのクエリも、フォームも、Server Actionsの引数も、外から来た値はそのままでは信用できません。Zodを使えば、検証とTypeScriptの型を一つのスキーマで扱えます。Zod 4 を前提に、よく使うパターンと、フォームでつまずきやすい点をまとめます。

スキーマと型を一緒に定義する

Zodでは、値の構造をスキーマとして定義します。文字列から深くネストしたオブジェクトまで扱えます。parseは成功した値を返し、失敗すればZodErrorを投げます。例外を避けたい境界ではsafeParseを使い、成功と失敗をタグ付きユニオンで受け取ります。

import { z } from "zod";

export const User = z.object({
  id: z.uuid(),
  name: z.string().min(1).max(64),
  bio: z.string().max(160).optional(),
});

export type User = z.infer<typeof User>;

const result = User.safeParse(input);

if (!result.success) {
  console.error(result.error.issues);
  return;
}

// result.data は User 型
console.log(result.data.name);

値と型は TypeScript 上で別の名前空間にあるため、同じ User という名前を使えます。スキーマを UserSchema、型を User と分ける方法も正しく、チームで読みやすい方へ統一すれば十分です。

エラーメッセージを整える

Zod 4 では、スキーマごとのエラーを error で指定できます。

const DisplayName = z
  .string({
    error: (issue) =>
      issue.input === undefined
        ? "表示名を入力してください"
        : "表示名は文字列で入力してください",
  })
  .min(2, { error: "表示名は2文字以上で入力してください" });

アプリケーション全体のメッセージを切り替えたい場合は z.config() とロケールを利用できます。ただし、UI にそのまま表示する文言は、項目の意味に合わせてスキーマ側で定義した方が親切なこともあります。

import { z } from "zod";

z.config(z.locales.ja());

ログに検証結果を残す場合は、入力にメールアドレスやパスワードなどが含まれていないかにも注意します。Zod は標準では issue に入力値を含めませんが、アプリケーション側で元データを一緒に出力すると、その安全策を失ってしまいます。

複数フィールドの関係を検証する

パスワードと確認用パスワードの一致など、複数の値にまたがる条件は refine で表せます。

export const PasswordForm = z
  .object({
    password: z.string().min(8),
    confirmation: z.string(),
  })
  .refine((data) => data.password === data.confirmation, {
    error: "パスワードが一致しません",
    path: ["confirmation"],
  });

独自の正規表現、refinetransform を含むスキーマはテストしておくと安心です。エラーオブジェクト全体は Zod の更新で形が変わる可能性があるため、利用者に見せるメッセージや path など、アプリケーションが依存する部分を検証します。

it("確認用パスワードが異なると失敗する", () => {
  const result = PasswordForm.safeParse({
    password: "password",
    confirmation: "different",
  });

  expect(result.success).toBe(false);
  if (!result.success) {
    expect(result.error.issues[0]).toMatchObject({
      path: ["confirmation"],
      message: "パスワードが一致しません",
    });
  }
});

URL のクエリを検証する

App Router の searchParamsURLSearchParams から受け取った値は、そのまま信用せず、ページの入口で検証します。冒頭の Number("")0 になる問題は、ここで潰します。

const optionalInteger = z.preprocess(
  (value) => (value === "" || value == null ? undefined : value),
  z.coerce.number().int().min(1).optional(),
);

const SearchQuery = z.object({
  q: z.string().trim().optional(),
  limit: optionalInteger,
  page: optionalInteger,
});

const result = SearchQuery.safeParse({
  q: searchParams.get("q") ?? undefined,
  limit: searchParams.get("limit") ?? undefined,
  page: searchParams.get("page") ?? undefined,
});

不正な値を catch(undefined) ですべて欠損扱いにすると、利用者の入力ミスと「指定なし」を区別できなくなります。既定値へフォールバックしてよい値なのか、エラーとして知らせるべき値なのかを先に決めます。

React Hook Form と組み合わせる

@hookform/resolverszodResolver を使うと、Zod の結果を React Hook Form のフィールドエラーへ接続できます。

"use client";

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

const CreateUser = z.object({
  username: z.string().min(3, { error: "3文字以上で入力してください" }),
  age: z.number().int().min(0).optional(),
  agreement: z.literal(true, { error: "同意が必要です" }),
});

type CreateUserInput = z.input<typeof CreateUser>;
type CreateUserOutput = z.output<typeof CreateUser>;

export function UserForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<CreateUserInput, unknown, CreateUserOutput>({
    resolver: zodResolver(CreateUser),
  });

  return (
    <form onSubmit={handleSubmit((data) => console.log(data))}>
      <label htmlFor="username">ユーザー名</label>
      <input id="username" {...register("username")} />
      <p>{errors.username?.message}</p>

      <label htmlFor="age">年齢</label>
      <input
        id="age"
        type="number"
        {...register("age", {
          setValueAs: (value) => (value === "" ? undefined : Number(value)),
        })}
      />

      <label>
        <input type="checkbox" {...register("agreement")} />
        利用規約に同意する
      </label>
      <p>{errors.agreement?.message}</p>

      <button type="submit">保存</button>
    </form>
  );
}

Zod の transformcoerce を使うと、入力型と出力型が異なる場合があります。そのときは z.inputz.output を分け、useForm<Input, Context, Output> の型引数へ渡すと、送信後の値まで型を保てます。

数値と日付の空欄

HTML の入力値は基本的に文字列です。valueAsNumber は便利ですが、空欄を NaN にします。空欄を許可する項目なら、上の例のように setValueAsundefined へ変換するか、Zod 側の preprocess で入口をそろえます。

日付も同様です。z.coerce.date() へ渡す前に、空文字を undefined にするなど、フォームの「未入力」をどの値で表すかを決めておくと扱いやすくなります。

ファイル入力

ブラウザで受け取る FileList は、必要なファイルを取り出してから検証します。サーバーでも同じスキーマを使うなら、その実行環境に File が存在するかを確認してください。

const UserIcon = z
  .custom<FileList>((value) => value instanceof FileList)
  .transform((files) => files.item(0))
  .refine((file) => file === null || file.size <= 3_000_000, {
    error: "ファイルサイズは3MB以下にしてください",
  })
  .refine((file) => file === null || file.type === "image/png", {
    error: "PNG形式の画像を選択してください",
  });

クライアント側の MIME type やサイズ検証だけで、アップロードを安全にできるわけではありません。サーバー側でも再検証し、保存先ではファイル名や実体の形式を含めて扱います。

Server Actions で検証する

Server Actions はサーバーで実行されるため、データベースや外部 API へ渡す直前の検証に向いています。クライアント側で検証していても、サーバー側の検証は省略できません。

// actions.ts
"use server";

import { z } from "zod";

const CreateUser = z.object({
  username: z
    .string()
    .trim()
    .regex(/^[a-zA-Z0-9_]{6,10}$/, {
      error: "英数字とアンダースコア6〜10文字で入力してください",
    }),
});

export type CreateUserState = {
  errors?: Record<string, string[]>;
  message?: string;
};

export async function createUser(
  _previousState: CreateUserState,
  formData: FormData,
): Promise<CreateUserState> {
  const result = CreateUser.safeParse({
    username: formData.get("username"),
  });

  if (!result.success) {
    return {
      errors: z.flattenError(result.error).fieldErrors,
    };
  }

  // result.data だけをデータベースや API へ渡す
  await saveUser(result.data);
  return { message: "保存しました" };
}

クライアントコンポーネントでは React の useActionState を使うと、Action が返したエラーと送信状態を表示できます。旧 useFormStateuseActionState へ移行しています。

"use client";

import { useActionState } from "react";
import { createUser, type CreateUserState } from "./actions";

const initialState: CreateUserState = {};

export function Form() {
  const [state, formAction, isPending] = useActionState(
    createUser,
    initialState,
  );

  return (
    <form action={formAction}>
      <label htmlFor="username">ユーザー名</label>
      <input
        id="username"
        name="username"
        aria-describedby="username-error"
        aria-invalid={Boolean(state.errors?.username)}
      />
      <p id="username-error" aria-live="polite">
        {state.errors?.username?.[0]}
      </p>

      <button type="submit" disabled={isPending}>
        {isPending ? "保存中…" : "保存"}
      </button>
    </form>
  );
}

useFormStatus を使う場合は、Hook を呼ぶコンポーネントを対象の <form> の子としてレンダーします。送信ボタンだけを独立したコンポーネントにすると、複数のフォームでも再利用しやすくなります。

さいごに

Zodを入れると、信頼できない値を受け取る場所がコード上ではっきりします。型が手に入るのは、その副産物です。

スキーマはどこでも無差別に使うのではなく、URL、フォーム、API、永続化の直前といった境界に置きます。エラーは利用者が直せる言葉に変え、変換前後の型を区別する。この方針を守ると、Next.js のデータフローはかなり見通しよくなります。

参考資料