React + Laravel の管理画面で、ユーザー編集フォームの共通フィールドはこう定義されています。
export const userBaseSchema = {
lastName: z.string().min(1, '姓は必須です').max(USER_FORM_LIMITS.lastName, '姓は{value}文字以内です'),
firstName: z.string().min(1, '名は必須です').max(USER_FORM_LIMITS.firstName, '名は{value}文字以内です'),
email: z.email('メール形式が不正です').max(USER_FORM_LIMITS.email, 'メールは{value}文字以内です'),
birthday: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, '日付形式が不正です'),
gender: z.enum(GENDERS, '性別が不正です。'),
};
姓・名・メール・生年月日・性別。ユーザー作成、ユーザー更新、プロフィール編集で共通する5フィールドが、1箇所にまとまっています。実際、この userBaseSchema は現在も5つのスキーマから展開されて使われています。
一方で、スキーマファイルを並べると、こうなっています。
frontend/src/features/user/schema/
├── userBaseSchema.ts # 共通フィールド定義
├── userFormSchema.ts # フォーム入力用(zodResolver)
├── createUserSchema.ts # 作成リクエスト用(送信直前のparse)
├── updateUserSchema.ts # 更新リクエスト用(送信直前のparse)
├── userPasswordSchema.ts # パスワード変更フォーム用
└── index.ts
frontend/src/features/profile/schema/
├── profileSchema.ts # フォーム入力用(zodResolver)
├── updateProfileSchema.ts # 更新リクエスト用(送信直前のparse)
├── profileProfileSchema.ts # 中身は profilePasswordSchema(パスワード変更フォーム用)
├── updateProfilePasswordSchema.ts # 未使用
└── index.ts
共通化は成立しているのに、それ自身を含めてスキーマは9つのファイルに分かれています。
この記事では、実際にこうなった経過を短く追ったあと、自分のコードにも同じパターンがないか確認できる3つの観点として整理し直します。単なる「Zodの使い方」の解説ではなく、実際に分岐したこのリポジトリの実装が題材です。
分岐までの経過(圧縮)
もともと userBaseSchema を含むスキーマは、1つの userSchema.ts にまとまっていました。それが userBaseSchema / userFormSchema / createUserSchema / updateUserSchema の4ファイルへ分割された時点では、4つとも実質的に同じ内容でした。
最初の分岐は、ユーザーの新規登録でパスワード入力欄を追加(448c80c)です。パスワード欄を作成時にだけ必須にしたい、という要件が入り、userFormSchema 側は superRefine で作成時だけ条件分岐、createUserSchema 側は .refine() で必須というパスワード一致チェックが、独立した書き方で2箇所に生まれました。当時を振り返ると、次のように考えていました。
最初から完成形として責務分離を設計したわけではない。実装を進める中で必要なスキーマを追加していった結果、別系統に分かれた。直接的な理由の一つは、パスワード欄をユーザー作成時にだけ設けたかったこと。作成と更新でフォーム要件が異なるため、スキーマを分ける必要が生じた。
その13日後、プロフィール更新画面(0e15ca3)で profileSchema / updateProfileSchema が userBaseSchema を再利用し始めます。同じコミットで、password / passwordConfirmation は userBaseSchema から userFormSchema 側へ押し戻されました。共通baseが新しい消費者を得るたびに、抱え込めるフィールドが再調整される、という動きです。
翌日の プロフィールのパスワード変更画面(6df7fd6)で、パスワード変更専用のスキーマ(profilePasswordSchema・updateProfilePasswordSchema)が追加されます。ここで生まれたのが、次の確認観点3で扱う「配線されなかったスキーマ」です。
ここから先は、自分のコードを見直すときに使える3つの観点として示します。
確認観点1: 送信直前の.parse()は、フォーム側の検証と同じルールを重複させていないか
作成・更新は、編集画面 → 確認画面 → 送信直前の .parse() → 送信、という2段階です。
if (isCreate) {
const request = userMapper.toCreateRequest(formData);
const validateRequest = createUserSchema.parse(request);
await createUserMutation.mutateAsync(validateRequest);
} else {
const request = userMapper.toUpdateRequest(formData);
const validateRequest = updateUserSchema.parse(request);
await updateUserMutation.mutateAsync(validateRequest);
}
userFormSchema から推論された UserFormData を、userMapper.toCreateRequest で CreateUserRequest の形へ詰め替えたうえで、送信直前にもう一度 .parse() しています。
toCreateRequest(fromData: UserFormData): CreateUserRequest {
return {
lastName: fromData.lastName, firstName: fromData.firstName, email: fromData.email,
birthday: fromData.birthday, gender: fromData.gender,
password: fromData.password ?? '',
passwordConfirmation: fromData.passwordConfirmation ?? '',
};
},
UserFormData.password は userFormSchema 上 optional() ですが、CreateUserRequest.password は createUserSchema 上必須の文字列です。型を合わせるための ?? '' が挟まっていて、パスワードが空のまま渡ってきても、この関数自体はエラーになりません。作成時にパスワードが空でないことは、userFormSchema の superRefine(フォーム側)と createUserSchema.parse()(送信直前)の両方で、独立にチェックされています。
確認したいこと: フォーム入力用スキーマと送信直前防御用スキーマを分けているとき、同じバリデーションルール(今回はパスワード一致)を、それぞれ別の書き方(superRefine / refine)で二重に持っていないか。
確認観点2: 確認画面の有無は、似た機能の間で揃っているか
作成・更新・プロフィール更新は「フォーム検証 → 確認画面 → 送信直前のもう一段の検証」という2段階でしたが、パスワード変更(ユーザー・プロフィールとも)は違います。
const { control, handleSubmit, ... } = useForm<ProfilePasswordFormData>({
resolver: zodResolver(profilePasswordSchema),
defaultValues: { currentPassword: '', password: '', passwordConfirmation: '' },
});
const onSubmit = async (data: ProfilePasswordFormData) => {
try {
await mutation.mutateAsync(data);
navigate(ROUTES.profile());
} catch (error) {
if (isValidationError(error) && error.errors) {
applyServerErrors(error.errors, setError);
}
}
};
zodResolver(profilePasswordSchema) によるフォーム側の検証だけを経て、そのまま mutateAsync で送信されます。送信直前の .parse() はありません。この違いが意図されたものかどうかは、Git履歴・コードからは確認できていません。分かっていません。
確認したいこと: 似た機能(作成・更新・関連機能)の間で、確認画面や送信直前の検証の有無が揃っているか。揃っていない場合、それが意図された設計か、確認できているか。
確認観点3: 「準備のために作ったスキーマ」は配線されているか
updateProfilePasswordSchema.ts です。
import { profilePasswordSchema } from '@/features/profile/schema';
export const updateProfilePasswordSchema = z.object({
profilePasswordSchema,
});
{ ...profilePasswordSchema.shape } のようにフィールドを展開せず、profilePasswordSchema という名前のキー1つの中にスキーマ全体を入れ子にしています。生成される型は { profilePasswordSchema: { currentPassword, password, passwordConfirmation } } という構造になり、{ currentPassword, password, passwordConfirmation } というフラットな入力データに対してこのスキーマで検証しようとしても噛み合いません。
このスキーマを実際に使っているコード(zodResolver の指定、.parse() の呼び出しのどちらか)を探しましたが、見つかりませんでした。作成された 6df7fd6 の時点で、同じコミットの ProfilePasswordPage.tsx は updateProfilePasswordSchema ではなく profilePasswordSchema を直接指定しています。それ以降のコミット履歴を確認しても、updateProfilePasswordSchema がいずれかの画面コンポーネントから参照されたコミットは見つかりませんでした。
このスキーマについて、当時を振り返ると、次のとおりです。
updateProfilePasswordSchemaは、User用のスキーマをコピーしてProfile側の実装を準備していたことの名残です。最初から明確な完成形として必要性を確定して作ったというより、実装しながらProfile側のschemaを準備する過程で作成し、結果的に不要になりました。
ユーザー側で使っていたのと同じパターンのスキーマをコピーして、プロフィール側の実装を準備する過程で作られ、実装を進めた結果 profilePasswordSchema 側だけで足りたため配線されないまま残った、というのが当時の経緯です。「一度使われていて、後から使われなくなった」わけではなく、Git履歴の確認結果(参照されたコミットが見つからない)とも矛盾しません。
確認したいこと: 別の実装を準備するためにコピーしたスキーマが、配線されないまま残っていないか。
補足: 同じ問題への、もう1つの解き方
フロントエンドのZodスキーマが「作成と更新でルールが違う」という問題にどう対応してきたかを見てきましたが、バックエンド(Laravel)側の UserRequest は書き方が違います。1つの UserRequest クラスの中で isMethod('post') によって条件分岐しており、そのコメントには「ルール差分が3つ以上発生したら、StoreUserRequest / UpdateUserRequest に分割すること」という基準が明示されています。
同じリポジトリの中に、「作成・更新の差分を1つのスキーマ内の条件分岐で吸収する」方式(Laravel側、そしてフロントエンドの userFormSchema の superRefine も同じ発想)と、「作成用・更新用を別ファイルに分ける」方式(フロントエンドの createUserSchema / updateUserSchema)が共存しています。フロントエンド側は、この2つの方式を「フォーム入力用」と「送信直前防御用」という異なる用途で同時に採用していました。
まとめ
「単一の真実」はスキーマファイルを1個にまとめることではないのだと思います。同じルール(今回はパスワード一致チェック)を複数箇所で独立して持たない状態を保つことであり、それができなかった箇所が、今回見てきた重複と updateProfilePasswordSchema という未使用スキーマでした。
自分のZodスキーマ構成を見直すときの確認事項として、今回の観点をまとめておきます。
- 共通スキーマ(base)を複数の用途別スキーマが展開しているとき、同じバリデーションルールを2箇所以上に書いていないか
-
フォーム入力用の検証と、送信直前の検証(
.parse())で、同じチェックを別の書き方(superRefine/refine)で重複させていないか - 似た機能(作成・更新・関連機能)の間で、確認画面や送信直前の検証の有無が揃っているか
- 「別の実装を準備するためにコピーしたスキーマ」が、配線されないまま残っていないか
題材にしたリポジトリは公開しているので、記事中のコミットSHAをそのまま git show で追試できます。
React / TypeScript / Laravel を中心としたポートフォリオや、これまでの経験・スキルについては以下にまとめています。
各確認観点の背景にあるコミット単位の経緯を、Git履歴から追ったZenn版も公開しています。