0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Zodスキーマはフォームの単一の真実にできるか — 作成・更新で分岐した実装から考える

0
Last updated at Posted at 2026-08-26

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版も公開しています。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?