ZodでFE/BEバリデーションを一元管理する方法

フロントエンドとバックエンドで同じバリデーションルールを書いていませんか?「メールアドレスの形式チェック」「文字数制限」など、同じルールを2箇所で管理していると、どちらかを更新し忘れて不整合が起きる…というのはよくある話ですよね。

海外のDDDコミュニティでは「Single Source of Truth(信頼できる唯一の情報源)」という概念が重視されていて、バリデーションルールも1箇所に集約すべきとされています。今回は、Zodを使ってこれを実現する方法を紹介します。

よくある問題:バリデーションが散らばる

典型的なプロジェクトだと、こんな感じになりがちです。

フロントエンド(React Hook Form + Zod):

const schema = z.object({
  email: z.string().email(),
  displayName: z.string().min(1).max(50),
});

バックエンド(Express/Hono):

const validateRequest = (req) => {
  if (!req.body.email || !isValidEmail(req.body.email)) {
    throw new Error('Invalid email');
  }
  if (!req.body.displayName || req.body.displayName.length > 50) {
    throw new Error('Invalid displayName');
  }
};

同じ「50文字以内」というルールが2箇所に書かれています。これを海外では**Anemic Domain Model(貧血ドメインモデル)**と呼んでいて、ドメインのルールがあちこちに散らばっている状態を指します。

Value Objectでルールを集約する

解決策は、ドメイン層にValue Objectとしてルールを定義することです。

// packages/domain/src/profiles/profile-display-name.ts
import { z } from "zod";

const rules = z
  .string()
  .min(1, "表示名を入力してください")
  .max(50, "表示名は50文字以内で入力してください")
  .brand("ProfileDisplayName");

export type ProfileDisplayName = z.infer<typeof rules>;

export const ProfileDisplayName = {
  rules,
  create: (value: string): ProfileDisplayName => {
    return value as ProfileDisplayName;
  },
};

ポイントは3つあります。

  1. rules: Zodスキーマそのもの。バリデーションルールの本体
  2. create: ファクトリ関数。値を生成する
  3. brand: TypeScriptの型を一意にする(stringと区別できる)

これでバリデーションルールが1箇所に集約されました。

フォームフィールドを宣言的に定義する

ここからが本題です。Value Objectを定義したら、フロントエンドのフォームでどう使うか。

まず、Value Objectからフォームフィールドを生成するファクトリを作ります。

// フィールドファクトリを定義
export const profileDisplayNameField = createFormFieldFactory(
  ProfileDisplayName,
  {
    messages: ({ type }) => {
      if (type === 'too_small') return '表示名を入力してください';
      if (type === 'too_big') return '表示名は50文字以内で入力してください';
      return '表示名が無効です';
    },
  }
);

すると、フォームスキーマがこう書けるようになります。

// フォームスキーマ(たった3行)
export const profileFormSchema = createFormSchema({
  displayName: profileDisplayNameField({ required: true }),
  bio: profileBioField({ required: false }),
  color: profileColorField({ required: true }),
});

これ、かなり宣言的じゃないですか?

required: truefalseかを指定するだけで、同じValue Objectから必須フィールド・任意フィールドを切り替えられます。バリデーションルールはProfileDisplayName.rulesから自動で引き継がれるので、重複はゼロです。

バックエンドでも同じルールを使う

バックエンド(Supabase Edge Functions / Hono)でも、同じValue Objectを参照します。

// supabase/functions/_shared/schemas/create-issue-params.ts
import { z } from "zod";
import { IssueTitle } from "@ralie/domain/issues/issue-title.ts";
import { IssuePriority } from "@ralie/domain/issues/issue-priority.ts";

export const CreateIssueParamsSchema = z.object({
  projectId: ProjectId.rules,
  title: IssueTitle.rules,      // ← ドメインのrulesを直接使用
  priority: IssuePriority.rules.optional(),
});

APIエンドポイントではzValidatorで検証します。

app.post(
  '/',
  authWithUserMiddleware,
  zValidator('json', CreateIssueParamsSchema),  // ← これだけ
  async (c) => {
    const validatedData = c.req.valid('json');
    // ...
  }
);

フロントエンドで「50文字以内」と定義したら、バックエンドでも自動的に「50文字以内」になります。ルールの不整合は原理的に発生しません。

ディレクトリ構成

モノレポでの構成はこんな感じです。

packages/
  domain/
    src/
      issues/
        issue-title.ts      # Value Object
        issue-status.ts     # enumのValue Object
      profiles/
        profile-display-name.ts

apps/
  mobile/
    src/
      forms/
        fields.ts           # フィールドファクトリ定義
        profile-form.ts     # フォームスキーマ
      libs/
        form/
          schema.ts         # createFormFieldFactoryByValueObject

supabase/
  functions/
    _shared/
      schemas/
        create-issue-params.ts   # APIスキーマ

packages/domainがフロントエンド・バックエンド両方から参照される「Single Source of Truth」になっています。

enumの場合

ステータスのようなenumも同じパターンで定義できます。

// packages/domain/src/issues/issue-status.ts
export const IssueStatusValues = [
  "open",
  "in_progress",
  "resolved",
  "closed",
] as const;

const rules = z.enum(IssueStatusValues).brand("IssueStatus");

export type IssueStatus = z.infer<typeof rules>;

export const IssueStatus = {
  values: IssueStatusValues,
  rules,
  create: (value: string): IssueStatus => { /* ... */ },

  // ドメインロジックもここに
  Open: "open" as IssueStatus,
  InProgress: "in_progress" as IssueStatus,

  isActive: (status: IssueStatus): boolean => {
    return status === "open" || status === "in_progress";
  },

  canTransitionTo: (from: IssueStatus, to: IssueStatus): boolean => {
    // 状態遷移のルールを定義
    const transitions: Record<string, IssueStatus[]> = {
      open: [IssueStatus.InProgress, IssueStatus.Resolved],
      // ...
    };
    return transitions[from]?.includes(to) || false;
  },
};

バリデーションルールだけでなく、ドメインロジック(isActivecanTransitionToなど)も同じ場所に集約できます。これがDDDでいう「リッチドメインモデル」ですね。

実際に得られたメリット

この構成にしてから、いくつか明確なメリットがありました。

1. バリデーションルールの変更が1箇所で済む

「50文字」を「100文字」に変えたいとき、profile-display-name.tsだけ修正すればOK。フロントエンドもバックエンドも自動で追従します。

2. フォーム定義が読みやすい

displayName: profileDisplayNameField({ required: true }),

この1行で「何のフィールドか」「必須かどうか」「バリデーションルール」が全部わかります。

3. 型安全

ProfileDisplayName型とstring型は区別されるので、誤って生の文字列を渡すとコンパイルエラーになります。

注意点

いくつか注意点もあります。

ビルド設定が必要

モノレポでパッケージを共有するには、TypeScriptのパス設定やビルド設定が必要です。最初のセットアップはちょっと面倒かもしれません。

過度な抽象化に注意

すべてのフィールドをValue Objectにする必要はありません。単純なstringnumberはそのままでいいケースもあります。ドメイン的に意味のある値(メールアドレス、ユーザー名、ステータスなど)に絞るのがおすすめです。

まとめ

ZodでValue Objectを定義し、フロントエンドのフォームとバックエンドのAPIバリデーションを一元管理する方法を紹介しました。

海外のDDDコミュニティでは「Single Source of Truth」として知られているパターンですが、Zodを使うとかなりシンプルに実現できます。フォームフィールドファクトリを作れば、宣言的な記述でフォームを定義できるのも便利です。

モノレポで開発している方は、ぜひ試してみてください。