1
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?

【React】zodのbrandで値オブジェクトを実装する(classを使わない方法)

1
Posted at

はじめに

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%を下回ってはいけない

実装

salesPrice.ts
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

1
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
1
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?