?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"],
});
独自の正規表現、refine、transform を含むスキーマはテストしておくと安心です。エラーオブジェクト全体は 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 の searchParams や URLSearchParams から受け取った値は、そのまま信用せず、ページの入口で検証します。冒頭の 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/resolvers の zodResolver を使うと、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 の transform や coerce を使うと、入力型と出力型が異なる場合があります。そのときは z.input と z.output を分け、useForm<Input, Context, Output> の型引数へ渡すと、送信後の値まで型を保てます。
数値と日付の空欄
HTML の入力値は基本的に文字列です。valueAsNumber は便利ですが、空欄を NaN にします。空欄を許可する項目なら、上の例のように setValueAs で undefined へ変換するか、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 が返したエラーと送信状態を表示できます。旧 useFormState は useActionState へ移行しています。
"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 のデータフローはかなり見通しよくなります。