メインコンテンツまでスキップ

第12章 値オブジェクト入門:ルール込みの値を作る🎁💎

第12章 値オブジェクト入門:ルール込みの値を作る🎁💎

この章のゴールはこれっ👇😊✨ **「ただの string / number を、“意味とルールを持った値”に進化させて、無効な値が入り込めない形をつくる」**ことだよ〜🛡️✨


0. まず一言:いまのTypeScriptまわり、どうなってる?👀🧠

本日時点だと、typescriptnpm上の最新安定版(latest)は 5.9.3 だよ📦✨ (NPM) そして TypeScript は “次の大きい波(TypeScript 6/7、Goでのネイティブ実装)” に向けて動いてる、って公式が進捗も出してるよ🌊🚀 (Microsoft for Developers) (でもこの章の「値オブジェクト」はバージョンが変わってもずっと効く設計の基礎だから安心してね😊💎)


1. 値オブジェクト(Value Object)ってなに?🎁✨

一言でいうと👇 **「値そのものに “意味” と “守るべきルール(不変条件)” を抱かせたもの」**だよ💎🛡️

たとえば:

  • Email:メールの形になってて、余計な空白がなく、正規化も済んでる📧✨
  • Money:通貨が決まってて、マイナス禁止、端数の扱いも決めてある💰🧾
  • Quantity:0以上、上限あり、整数のみ📦🔢
  • DateRange:開始 <= 終了📆✅

つまり… **「検証済みの値」**を作って、アプリの中核ではそれを信じて扱えるようにする感じだよ〜😊🛡️✨


2. なんでVOが必要なの?(超リアルな事故)😱💥

stringstring は、TypeScript的には同じ “ただの文字列” だよね?🥲

だからこういう事故が起きる👇💥

  • userIdemail を取り違えて渡しても、コンパイルが通っちゃう😱
  • いろんな場所で if (!email.includes("@")) ... が散らばって漏れる🌀
  • “空白入り” や “大文字小文字の揺れ” が原因で、同じ人が別人扱いになる😵‍💫

VOを入れると👇 **「ドメインの中は “検証済みの値だけ” にできる」**から、チェック地獄がスッと減るよ🧹✨


3. VOの特徴(ここ押さえたら勝ち)🏆💎

✅ 3.1 “値で同一性” を見る(Entityと違う)🧠✨

  • Entity(エンティティ):IDで同一性(同じ人=同じID)👤
  • Value Object:値が同じなら同じ(同じEmail=同じEmail)📧

✅ 3.2 だいたい不変(Immutable寄り)🧊✨

VOは「後から勝手に書き換え」できると不変条件が壊れるから、基本は 作ったら固定 が相性いいよ🔒😊

✅ 3.3 正規化(Normalization)しやすい🧼✨

例:Emailなら trim() して小文字化する、みたいな「同じ意味なら同じ形」に寄せられる🫧


4. TypeScriptでVOを作る“定番2パターン”🔧✨

この章では 2つの作り方 を紹介するね😊 (次章で「コンストラクタ隠して create/parse」に進化させるよ〜🏭🔐)


パターンA:Branded(Opaque風)で “ただのプリミティブ” を進化させる🏷️✨

良いところ:軽い!速い!JSONにもそのまま載せやすい!🚀 注意点:メソッドを生やしにくい(関数で操作する感じ)🧩

// domain/valueObjects/email.ts

declare const emailBrand: unique symbol;

export type Email = string & { readonly [emailBrand]: "Email" };

export type EmailError =
| { type: "Empty" }
| { type: "TooLong" }
| { type: "InvalidFormat" };

type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };

const MAX_EMAIL_LENGTH = 254;

export function createEmail(input: string): Result<Email, EmailError> {
const normalized = input.trim().toLowerCase();

if (normalized.length === 0) return { ok: false, error: { type: "Empty" } };
if (normalized.length > MAX_EMAIL_LENGTH) return { ok: false, error: { type: "TooLong" } };

// 超ゆるめチェック(本格化は後の章でOKだよ😊)
const at = normalized.indexOf("@");
if (at <= 0 || at === normalized.length - 1) {
return { ok: false, error: { type: "InvalidFormat" } };
}

return { ok: true, value: normalized as Email };
}

export function emailToString(email: Email): string {
return email; // ただのstringとして使えるのが嬉しい✨
}

使う側👇

import { createEmail } from "./domain/valueObjects/email";

const r = createEmail(" ALICe@Example.com ");
if (!r.ok) {
console.log("ダメだった😢", r.error.type);
} else {
const email = r.value; // Email 型になった🎉
console.log("OK✅", email);
}

メモ📝 “本格メール仕様(RFC)に完全準拠” は難易度高いので、現場では 「現実的に通す+確認メールで担保」 みたいに割り切ることも多いよ🙂✨(この教材もその路線で進めるよ〜)


パターンB:classで “ふるまい付きVO” にする🧸✨

良いところequals() とか format() とかメソッドを持てる💪 注意点:保存・通信のときにプリミティブへ変換する設計が必要📦

// domain/valueObjects/money.ts

export type Currency = "JPY" | "USD";

export type MoneyError =
| { type: "Negative" }
| { type: "NotInteger" };

type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };

// ここでは「最小単位」で整数管理(JPYなら1円単位、USDならセント単位)みたいな気持ちでOK😊
export class Money {
private constructor(
private readonly amountMinor: number,
private readonly currency: Currency
) {}

static create(amountMinor: number, currency: Currency): Result<Money, MoneyError> {
if (!Number.isInteger(amountMinor)) return { ok: false, error: { type: "NotInteger" } };
if (amountMinor < 0) return { ok: false, error: { type: "Negative" } };
return { ok: true, value: new Money(amountMinor, currency) };
}

add(other: Money): Money {
if (this.currency !== other.currency) {
// ここは本当はドメインエラーにしたいけど、今日は簡略版😊
throw new Error("Currency mismatch");
}
return new Money(this.amountMinor + other.amountMinor, this.currency);
}

equals(other: Money): boolean {
return this.currency === other.currency && this.amountMinor === other.amountMinor;
}

toJSON(): { amountMinor: number; currency: Currency } {
return { amountMinor: this.amountMinor, currency: this.currency };
}
}

5. 「VOにする候補」の見つけ方🔍✨

次のどれかに当てはまったらVO候補だよ〜😊💎

  • ルールがある(0以上、形式、範囲、上限…)🛡️
  • 意味が強い(Email、UserId、Plan、Quantity…)🏷️
  • 同じプリミティブが多い(stringだらけ/numberだらけ)🧟‍♀️
  • 正規化したい(trim、大小文字、全角半角…)🧼
  • 比較が頻繁(同じ?違う?がよく出る)⚖️

6. “境界→VO→ドメイン” の完成イメージ(超ミニ)🚪➡️💎➡️🏰

境界では string が来る。 でもドメイン内部では Email だけにしたい…!

import { createEmail, Email } from "./domain/valueObjects/email";

type RegisterCommand = {
email: Email; // もうstringじゃない💎
};

function toRegisterCommand(input: { email: string }): RegisterCommand {
const r = createEmail(input.email);
if (!r.ok) {
// 境界で止める🚧(ここでユーザー向けに優しく返すのは後の章で✨)
throw new Error(`Invalid email: ${r.error.type}`);
}
return { email: r.value };
}

この形ができると、ドメインの中でこう言えるようになるよ👇😊 「Emailは必ず正しい形で入ってくる」 🛡️✨


7. よくあるミス集(先に潰しておこ)🧯😆

❌ ミス1:VOなのに誰でも作れる(検証スキップ)😱

  • type Email = string に戻っちゃうやつ🥲 → create関数(or private constructor)経由に寄せよう🔐✨(次章で本格化!)

❌ ミス2:VOがミュータブル(後から書き換え可)🌀

→ 作成後は触れない設計に🧊🔒

❌ ミス3:VOが巨大すぎる(何でも背負う)🐘

→ ルールが同じ塊だけに。迷ったら小さく分けよ🧁✨


8. ミニ課題(この章のゴール確認)🎯✨

課題A:VO候補を2つ選ぶ🎁

あなたの題材から、VOにしたい値を2つ選んでね😊 例:Email, UserId, Quantity, Price, TagName など✨

課題B:どっちのパターンで作るか決める🏷️🧸

  • “軽くてプリミティブっぽく使いたい” → Branded型🏷️
  • “操作(メソッド)を持たせたい” → class🧸

課題C:不変条件を3つ書く🛡️📝

例(Quantityなら)

  • 0以上
  • 整数
  • 9999以下(上限)

9. AI活用(この章は相性めちゃ良い)🤖💖

VOは「ルールの洗い出し」「テスト観点」が命だよ〜🧠✨ AIにはここを手伝わせるのが最強😊💪

コピペで使える質問テンプレ👇💬

  • Email の入力ゆれ(空白、大文字小文字、変な文字)パターンを20個出して」🌀
  • Quantity の境界値テストを列挙して(成功/失敗)」🧪
  • 「この createEmail() の実装で漏れてる観点ある?」🔍
  • 「ユーザー向けに優しいエラーメッセージ案を5つ出して」🫶

10. まとめ(この章で手に入れた武器)🛡️💎

  • string/number意味ある型(VO) にできた🎁✨
  • 検証&正規化済みの値だけ をドメインに入れる方向が見えた🚪➡️💎
  • Branded型 / class型、どっちでも作れる感覚がついた🏷️🧸

次の章(第13章)は、いよいよ本丸🔥 「無効な状態を“作れない”ために、コンストラクタ隠して create/parse に統一する🏭🔐」 をやるよ〜😊✨


必要なら、この章のサンプルをあなたの題材(会員登録/注文/課金など)に合わせて、VO候補の提案&雛形コードまで一気に作るよ📦💖