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つあります。
rules: Zodスキーマそのもの。バリデーションルールの本体create: ファクトリ関数。値を生成する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: trueかfalseかを指定するだけで、同じ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;
},
};
バリデーションルールだけでなく、ドメインロジック(isActive、canTransitionToなど)も同じ場所に集約できます。これがDDDでいう「リッチドメインモデル」ですね。
実際に得られたメリット
この構成にしてから、いくつか明確なメリットがありました。
1. バリデーションルールの変更が1箇所で済む
「50文字」を「100文字」に変えたいとき、profile-display-name.tsだけ修正すればOK。フロントエンドもバックエンドも自動で追従します。
2. フォーム定義が読みやすい
displayName: profileDisplayNameField({ required: true }),
この1行で「何のフィールドか」「必須かどうか」「バリデーションルール」が全部わかります。
3. 型安全
ProfileDisplayName型とstring型は区別されるので、誤って生の文字列を渡すとコンパイルエラーになります。
注意点
いくつか注意点もあります。
ビルド設定が必要
モノレポでパッケージを共有するには、TypeScriptのパス設定やビルド設定が必要です。最初のセットアップはちょっと面倒かもしれません。
過度な抽象化に注意
すべてのフィールドをValue Objectにする必要はありません。単純なstringやnumberはそのままでいいケースもあります。ドメイン的に意味のある値(メールアドレス、ユーザー名、ステータスなど)に絞るのがおすすめです。
まとめ
ZodでValue Objectを定義し、フロントエンドのフォームとバックエンドのAPIバリデーションを一元管理する方法を紹介しました。
海外のDDDコミュニティでは「Single Source of Truth」として知られているパターンですが、Zodを使うとかなりシンプルに実現できます。フォームフィールドファクトリを作れば、宣言的な記述でフォームを定義できるのも便利です。
モノレポで開発している方は、ぜひ試してみてください。