はじめに
DDD(ドメイン駆動設計)でよく登場する「値オブジェクト」は、classで実装する例が多く見られます。しかしReactでは、classのインスタンスはstate管理やシリアライズ(Server Components、localStorage、状態管理ライブラリなど)と相性がよくありません。
この記事では、classを使わずにzodのbrand機能+関数モジュールで値オブジェクトを実装する方法を、ECサイトの「販売価格」を題材に紹介します。
対象読者
- Reactで値オブジェクトを使いたい人
- zodをバリデーション以外にも活用したい人
問題
値オブジェクトに求められる性質は主に以下の4つです。
- 生成時に不正な値を弾く(不変条件の保証)
- 他の値と型で区別できる(
Emailとstringを取り違えない) - 不変である
- ドメインロジック(振る舞い)を持てる
classで書けばこれらを1つにまとめられますが、Reactでは次のような問題が出ます。
-
useStateに入れたインスタンスの比較・更新が扱いにくい - JSONにするとメソッドが消え、復元時に再度インスタンス化が必要
- Server Componentsからクライアントへ渡せない
一方で、zodだけで「検証」はできても、そのままでは振る舞いや型の区別は持てません。
解決方法
方針
-
データ:zodスキーマ+
.brand()でplainなオブジェクトとして定義 - 振る舞い:同じモジュールに関数として定義
- スキーマと型を同名でexportし、値としても型としても使えるようにする
お題:販売価格(SalesPrice)の仕様
- 税抜で保持
- 0円以上1,000,000円以下の整数
- 通貨はJPYのみ
- 税込価格は別の型(
TaxIncludedPrice)とし、税抜と取り違えられないようにする - 割引後の価格は元値の50%を下回ってはいけない
実装
import { z } from "zod";
// ---- 販売価格(税抜) ----
export const SalesPrice = z
.object({
amount: z.number().int().min(0).max(1_000_000),
currency: z.literal("JPY"),
})
.readonly()
.brand<"SalesPrice">();
export type SalesPrice = z.infer<typeof SalesPrice>;
// ---- 税込価格 ----
export const TaxIncludedPrice = z
.object({
amount: z.number().int().nonnegative(),
currency: z.literal("JPY"),
})
.readonly()
.brand<"TaxIncludedPrice">();
export type TaxIncludedPrice = z.infer<typeof TaxIncludedPrice>;
// ---- 税率(%の整数で持つ) ----
export const TaxRate = z.union([z.literal(10), z.literal(8)]);
export type TaxRate = z.infer<typeof TaxRate>;
// ---- 割引率 ----
export const Percent = z.number().int().min(0).max(100).brand<"Percent">();
export type Percent = z.infer<typeof Percent>;
/** 税込価格(端数切り捨て) */
export function withTax(price: SalesPrice, rate: TaxRate): TaxIncludedPrice {
const amount = Math.floor((price.amount * (100 + rate)) / 100);
return TaxIncludedPrice.parse({ amount, currency: price.currency });
}
/** 割引後の販売価格(端数切り捨て、元値の50%未満は不可) */
export function discount(price: SalesPrice, percent: Percent): SalesPrice {
const amount = Math.floor((price.amount * (100 - percent)) / 100);
if (amount * 2 < price.amount) {
throw new Error("割引後の価格が元値の50%を下回っている");
}
return SalesPrice.parse({ amount, currency: price.currency });
}
export function equals(a: SalesPrice, b: SalesPrice): boolean {
return a.amount === b.amount && a.currency === b.currency;
}
使い方
const price = SalesPrice.parse({ amount: 1000, currency: "JPY" });
const off = discount(price, Percent.parse(20)); // 800
const taxed = withTax(off, 10); // 880
withTax(taxed, 10); // ❌ 型エラー:税込価格に再度税をかけられない
withTax(off, 5); // ❌ 型エラー:税率は 10 | 8 のみ
.brand() により、parse を通った値だけが SalesPrice 型になります。そのため税抜と税込の取り違えをコンパイル時に検出できます。
実装のポイント
浮動小数点を避けて整数で計算する
price * 1.1 や percent * 0.01 は浮動小数点誤差が出ます。税率・割引率を%の整数で持ち、× (100 + rate) / 100 の順で計算します。50%判定も amount * 2 < 元値 とし、小数を発生させません。
計算結果も必ず parse を通す
関数の戻り値もスキーマで検証することで、計算後の値が不変条件を満たすことを毎回保証できます。
parse と safeParse の使い分け
parse の引数は unknown 型なので、不正な値を渡してもエディタ上ではエラーになりません。検証は実行時に行われ、失敗すると ZodError が throw されます。
const email = Email.parse("test"); // 型エラーなし → 実行時に例外
ユーザー入力など失敗しうる値は safeParse を使い、success で分岐してから data を取り出します。
const result = SalesPrice.safeParse({ amount: Number(input), currency: "JPY" });
if (result.success) {
setPrice(result.data); // ここでは SalesPrice 型
} else {
setError(result.error.issues[0]?.message);
}
safeParse の戻り値は値そのものではなく結果オブジェクトなので、const p: SalesPrice = X.safeParse(...) と直接代入すると型エラーになる点に注意してください。
スキーマはコンポーネントの外で定義する
コンポーネント内に書くとレンダーごとに再生成されます。モジュールのトップレベルで定義しましょう。
classとの比較
| 観点 | class | zod+brand+関数 |
|---|---|---|
| 生成時の検証 | コンストラクタで実装 | スキーマで宣言的に記述 |
| 型の区別 | 公称型的に区別可能 |
.brand() で区別 |
| 振る舞い | メソッド | 関数(同じモジュール) |
| シリアライズ | メソッドが消える | plain objectなのでそのまま |
| フォーム連携 | 別途変換が必要 |
zodResolver にそのまま渡せる |
| ブランドの偽装 | private constructorで防げる |
as キャストで偽装可能 |
最後の項目はzod方式の弱点です。as SalesPrice を使わないルール(lintなど)でカバーするとよいでしょう。
おわりに
Reactで値オブジェクトを扱うなら、classよりもzodのbrand付きplain object+関数モジュールのほうが摩擦が少ないと感じました。
- 単純な値(Email、UserIdなど)→ zod+brandだけで十分
- ロジックを持つ値(金額、期間など)→ 同じモジュールに関数をまとめる
特に「税抜と税込を型で区別する」ように、似た数値を取り違えるバグを型で防げるのは大きなメリットです。
参考
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼
https://projisou.jp
