Value Object
プロンプトに「Value Object」を含めることで、ClaudeCodeはプリミティブ値を型安全で不変なオブジェクトとして扱う実装を生成できます。弊社では、処理やプロパティをクラスにまとめることでテストしやすくAIが読み書きしやすくするためにValue Objectパターンを採用します。
弊社の推奨ルール
- イミュータブルにする - コンストラクタでObject.freezeを呼ぶ
- Zodでバリデーション - スキーマ定義と型推論を活用
- プリミティブ値はgetterのみ - メールアドレス等は基本的にメソッド不要
- オブジェクト値はwithメソッド - 複数値を持つ場合のみwithXxxを定義
- 可読性向上のgetter - isValidなど判定ロジックをgetterで提供
ClaudeCodeでの利用
プリミティブ値の置き換え
数値や文字列をより型安全なオブジェクトに変換したい場合
「この価格のnumber型をPriceのValue Objectに変更して」
関連値のグループ化
複数の関連する値を一つのオブジェクトとして管理したい場合
「住所の都道府県、市区町村、番地をAddressのValue Objectにまとめて」
バリデーション機能の実装
値の妥当性チェックを含む型安全なオブジェクトを作成する場合
「メールアドレスのValue Objectを作成。形式チェックも含めて」
計算機能の集約
値に関連する計算処理をオブジェクト内に実装する場合
「金額の税込計算を含むMoneyのValue Objectを実装」
プリミティブ値の代わりにValue Objectを使う
弊社では、getterが必要な値やバリデーションを含む値はValue Objectとして定義します。プリミティブ値の直接使用は、条件判定の可読性低下とバリデーションの分散を招きます。
// 推奨しない:プリミティブ値の直接使用と分散した判定
function processEmail(email: string) {
// バリデーションが分散
if (!email.includes('@')) throw new Error('Invalid email');
if (email.length > 255) throw new Error('Email too long');
// 条件判定が直接記述されて可読性が低い
if (email.endsWith('@company.com')) {
// 社内メール処理
}
}
Value Objectを使用することで、getterによる可読性向上とバリデーションの集約を実現します。
// 推奨:プリミティブ値のValue Object(Zodでバリデーション)
import { z } from 'zod';
// Zodスキーマを定義
const zEmail = z.string()
.email('有効なメールアドレスではありません')
.max(255, 'メールアドレスは255文字以内');
type Value = z.infer<typeof zEmail>;
class Email {
private readonly value: string;
constructor(value: Value) {
// Zodでバリデーション
this.value = zEmail.parse(value);
Object.freeze(this);
}
// getterで可読性を向上
get isCompanyEmail(): boolean {
return this.value.endsWith('@company.com');
}
get domain(): string {
return this.value.split('@')[1];
}
toString(): string {
return this.value;
}
}
Emailクラスにより、バリデーションと判定ロジックが集約され、AIが読み書きしやすくなります。
// 使用例:getterで条件判定が明確
const email = new Email('user@company.com');
if (email.isCompanyEmail) {
console.log(`社内メール: ${email.domain}`);
}
複数の関連値の代わりにオブジェクトValue Objectを使う
弊社では、更新処理が複雑な場合のみValue Objectを定義します。何でもValue Objectにすると無駄に実装が複雑になるため、必要性を判断します。
// 推奨しない:関連値が分散
class User {
constructor(
private firstName: string,
private lastName: string,
private middleName?: string
) {}
getFullName(): string {
if (this.middleName) {
return `${this.firstName} ${this.middleName} ${this.lastName}`;
}
return `${this.firstName} ${this.lastName}`;
}
}
関連する値をValue Objectにまとめることで、名前に関する処理を集約できます。
// 推奨:オブジェクトValue Object(Zodでバリデーション)
const zPersonName = z.object({
first: z.string().min(1, '名は必須'),
last: z.string().min(1, '姓は必須'),
middle: z.string().optional()
});
type Value = z.infer<typeof zPersonName>;
複数の値を持つPersonNameクラスでは、withメソッドを定義して更新処理を集約します。
class PersonName {
private readonly first: string;
private readonly last: string;
private readonly middle?: string;
constructor(input: Value) {
const v = zPersonName.parse(input);
this.first = v.first;
this.last = v.last;
this.middle = v.middle;
Object.freeze(this);
}
// オブジェクト型は withメソッドを定義
withFirst(first: string): PersonName {
return new PersonName({
first,
last: this.last,
middle: this.middle
});
}
getterで計算結果や判定ロジックを提供し、可読性を向上させます。
// getterで可読性向上
get fullName(): string {
return this.middle
? `${this.first} ${this.middle} ${this.last}`
: `${this.first} ${this.last}`;
}
get hasMiddleName(): boolean {
return !!this.middle;
}
}
// 使用例:withメソッドで可読性と保守性が向上
class User {
constructor(
private readonly id: string,
private readonly name: PersonName
) {
Object.freeze(this);
}
withName(name: PersonName): User {
return new User(this.id, name);
}
// withメソッドチェーンで更新が明確
changeLastName(lastName: string): User {
return this.withName(this.name.withLast(lastName));
}
// getterで判定が読みやすい
get hasFullName(): boolean {
return this.name.hasMiddleName;
}
}
mutableな操作ではなくimmutableなメソッドを使う
弊社では、Value Objectの全メソッドは新しいインスタンスを返すように実装します。これにより、予期しない副作用を防ぎます。
// 推奨しない:mutableな操作
class MutableDate {
private year: number;
private month: number;
private day: number;
addDays(days: number): void {
this.day += days; // 直接変更
// 月またぎの処理...
}
}
immutableな実装により、元のオブジェクトを変更せずに新しい値を生成します。
// 推奨:immutableなValue Object
class DateValue {
constructor(
private readonly year: number,
private readonly month: number,
private readonly day: number
) {
this.validate();
Object.freeze(this);
}
private validate(): void {
if (this.month < 1 || this.month > 12) {
throw new Error('月は1-12の範囲である必要があります');
}
// その他のバリデーション
}
// 新しいインスタンスを返す
addDays(days: number): DateValue {
const date = new Date(this.year, this.month - 1, this.day);
date.setDate(date.getDate() + days);
return new DateValue(
date.getFullYear(),
date.getMonth() + 1,
date.getDate()
);
}
withYear(year: number): DateValue {
return new DateValue(year, this.month, this.day);
}
withMonth(month: number): DateValue {
return new DateValue(this.year, month, this.day);
}
withDay(day: number): DateValue {
return new DateValue(this.year, this.month, day);
}
// 比較メソッド
isBefore(other: DateValue): boolean {
if (this.year !== other.year) return this.year < other.year;
if (this.month !== other.month) return this.month < other.month;
return this.day < other.day;
}
equals(other: DateValue): boolean {
return this.year === other.year &&
this.month === other.month &&
this.day === other.day;
}
}
// 使用例:チェーンして操作
const today = new DateValue(2024, 1, 15);
const nextMonth = today
.addDays(30)
.withDay(1); // 翌月1日
// 元のオブジェクトは変更されない
console.log(today.equals(new DateValue(2024, 1, 15))); // true
よくある問題
Value Objectの等価性判定
JavaScriptの===演算子は参照の比較なので、equalsメソッドを実装します。
const money1 = new Money(1000);
const money2 = new Money(1000);
console.log(money1 === money2); // false(異なるインスタンス)
console.log(money1.equals(money2)); // true(値が同じ)
Union型でオプショナルなプロパティを減らす
オプショナルなプロパティを持つ値オブジェクトの代わりに、Union型で分割します。
// 推奨しない:オプショナルプロパティでバリデーションが複雑
class Payment {
constructor(
private readonly amount: number,
private readonly currency: string,
private readonly fee?: number, // オプショナル
private readonly tax?: number // オプショナル
) {
// feeとtaxの存在による複雑なバリデーション
if (fee && fee < 0) throw new Error('手数料は0以上')
if (tax && tax < 0) throw new Error('税金は0以上')
if (fee && !tax) throw new Error('手数料がある場合は税金も必須')
}
get total(): number {
// オプショナル値の処理が複雑
return this.amount + (this.fee ?? 0) + (this.tax ?? 0)
}
}
// 推奨:Union型で明確に分割
// シンプルな決済
class SimplePayment {
constructor(
private readonly amount: number,
private readonly currency: string
) {
if (amount < 0) throw new Error('金額は0以上')
Object.freeze(this)
}
get total(): number {
return this.amount
}
get type(): 'simple' { return 'simple' }
}
// 手数料付き決済
class PaymentWithFee {
constructor(
private readonly amount: number,
private readonly currency: string,
private readonly fee: number,
private readonly tax: number
) {
if (amount < 0) throw new Error('金額は0以上')
if (fee < 0) throw new Error('手数料は0以上')
if (tax < 0) throw new Error('税金は0以上')
Object.freeze(this)
}
get total(): number {
return this.amount + this.fee + this.tax
}
get type(): 'with_fee' { return 'with_fee' }
}
// Union型で使い分け
type Payment = SimplePayment | PaymentWithFee
// 使用例:型による明確な分岐
function processPayment(payment: Payment): void {
switch (payment.type) {
case 'simple':
console.log(`シンプル決済: ${payment.total}`)
break
case 'with_fee':
console.log(`手数料付き: ${payment.total}`)
break
}
}
Q: パフォーマンスへの影響は?
A: 弊社では型安全性とコードの品質を優先。パフォーマンスが問題になったら最適化を検討。
Q: すべての値をValue Objectにすべき?
A: IDや単純なフラグなど、getterやバリデーションが不要な値はプリミティブのままで良い。判定ロジックやバリデーションが必要な値のみValue Object化。
Q: プリミティブとオブジェクトの使い分けは?
A: メールアドレスや金額など単一値はプリミティブValue Object(withメソッド不要)。住所や名前など複数値はオブジェクトValue Object(withメソッド定義)。
Q: データベースとのマッピングは?
A: ORMのカスタムタイプやシリアライザーを使用。またはリポジトリ層で変換処理を実装。