Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 043 / 100

Value objects: Design Patterns best practices explained

Small immutable types that make invalid states impossible.

Expona Admin9 min read

Introduction

We've all seen code that lets invalid data slip through—a negative price, a malformed email stored happily in the database, a date range where the end comes before the start. Value objects: Design Patterns best practices explained, are small immutable types that make invalid states impossible by encoding validation and business rules directly into the type system. Rather than passing strings and numbers around and checking them repeatedly, we wrap them in purpose-built classes that validate once, compare by value, and eliminate the entire category of bugs caused by primitive obsession.

Over forty days into this series, we've covered structural and behavioral patterns. Value objects are different—they're about encoding domain logic into data structures so powerfully that invalid states become literally unreachable. Let's explore how to implement them correctly and when they pay for themselves.

The problem: primitive obsession

Consider a typical e-commerce system:

typescript
function createOrder(
  customerEmail: string,
  amount: number,
  startDate: Date,
  endDate: Date
) {
  // Are these valid? We don't know until runtime.
  // What if startDate > endDate?
  // What if amount is negative?
  // What if email is malformed?
  
  const order = {
    email: customerEmail,
    price: amount,
    bookingStart: startDate,
    bookingEnd: endDate
  };
  
  // We'll validate somewhere else. Maybe.
  return order;
}

The function accepts primitives—string, number, Date—without enforcing any constraints. Validation typically happens scattered across multiple layers: the controller validates, the service validates again, the database layer checks constraints. Bugs emerge when validation logic diverges or gets skipped. The type system can't help us because a string is a string, regardless of whether it contains a valid email.

Value objects solve this by making the type system your first line of defense.

Email: the canonical example

An email address is a perfect candidate for a value object. It has specific validation rules, it should be comparable by its string value (not object identity), and it appears across many domains—orders, users, notifications.

typescript
class Email {
  private constructor(readonly value: string) {}

  static create(input: string): Email {
    const normalized = input.trim().toLowerCase();
    
    if (!/^[^@]+@[^@]+\.[^@]+$/.test(normalized)) {
      throw new Error(`Invalid email: ${input}`);
    }
    
    return new Email(normalized);
  }

  equals(other: Email): boolean {
    return this.value === other.value;
  }

  toString(): string {
    return this.value;
  }
}

Now our function becomes:

typescript
function createOrder(
  customerEmail: Email,
  amount: Money,
  period: DateRange
) {
  // Typescript won't let us pass a string by mistake
  // We know these are valid because they were validated at construction
  
  return {
    email: customerEmail,
    price: amount,
    booking: period
  };
}

// Usage
const email = Email.create('customer@example.com'); // Throws if invalid
const order = createOrder(email, ...);

If someone tries to pass a raw string, TypeScript will reject it. Validation happens exactly once, at construction. Downstream code can't mess it up.

Money: precision and domain clarity

Pricing is notoriously fragile. Floating-point arithmetic causes rounding errors, negative amounts slip past validation, and currency information gets lost in data pipelines.

typescript
class Money {
  private constructor(
    readonly amount: number,
    readonly currency: string
  ) {}

  static create(amount: number, currency: string): Money {
    if (!Number.isFinite(amount)) {
      throw new Error('Amount must be a finite number');
    }
    
    if (amount < 0) {
      throw new Error('Amount cannot be negative');
    }
    
    if (!/^[A-Z]{3}$/.test(currency)) {
      throw new Error('Invalid currency code');
    }
    
    // Store as cents to avoid floating-point issues
    const amountInCents = Math.round(amount * 100);
    return new Money(amountInCents, currency);
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error('Cannot add different currencies');
    }
    return new Money(this.amount + other.amount, this.currency);
  }

  multiply(factor: number): Money {
    const result = Math.round(this.amount * factor);
    return new Money(result, this.currency);
  }

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

  display(): string {
    return `${(this.amount / 100).toFixed(2)} ${this.currency}`;
  }
}

Now arithmetic is type-safe and currency mismatches are caught immediately. The Money object handles the integer-based storage internally; callers work with natural decimals.

DateRange: expressing temporal constraints

Date ranges appear everywhere—bookings, subscriptions, contracts. Left unchecked, you get "end before start" bugs that corrupt data.

typescript
class DateRange {
  private constructor(
    readonly start: Date,
    readonly end: Date
  ) {}

  static create(start: Date, end: Date): DateRange {
    if (start > end) {
      throw new Error('Start date must be before end date');
    }
    
    if (start < new Date()) {
      throw new Error('Start date cannot be in the past');
    }
    
    return new DateRange(start, end);
  }

  durationDays(): number {
    return Math.ceil(
      (this.end.getTime() - this.start.getTime()) / (1000 * 60 * 60 * 24)
    );
  }

  overlaps(other: DateRange): boolean {
    return this.start < other.end && this.end > other.start;
  }

  contains(date: Date): boolean {
    return date >= this.start && date <= this.end;
  }

  equals(other: DateRange): boolean {
    return this.start.getTime() === other.start.getTime() &&
           this.end.getTime() === other.end.getTime();
  }
}

Domain logic—overlap detection, duration calculation—lives inside the value object. Callers don't need to know about temporal edge cases; the object handles them transparently.

PhoneNumber: internationalization

Phone numbers are deceptively complex. Formats vary by region, and storing them as raw strings invites a sea of validation bugs.

typescript
class PhoneNumber {
  private constructor(
    readonly countryCode: string,
    readonly number: string
  ) {}

  static create(input: string, countryCode?: string): PhoneNumber {
    // Simple validation; production would use libphonenumber or similar
    const cleaned = input.replace(/\D/g, '');
    
    if (cleaned.length < 10 || cleaned.length > 15) {
      throw new Error(`Invalid phone number length: ${cleaned.length}`);
    }
    
    const code = countryCode || '+1';
    return new PhoneNumber(code, cleaned);
  }

  formatted(): string {
    // Format depends on country code
    if (this.countryCode === '+1') {
      return `${this.countryCode} (${this.number.slice(0, 3)}) ${this.number.slice(3, 6)}-${this.number.slice(6)}`;
    }
    return `${this.countryCode} ${this.number}`;
  }

  equals(other: PhoneNumber): boolean {
    return this.countryCode === other.countryCode && 
           this.number === other.number;
  }
}

The value object handles region-specific formatting and validation. Callers pass a string and country code once; downstream code receives a normalized, validated object.

Comparison by value, not identity

A critical property of value objects is that they're compared by their content, not by object identity. Two Email objects with the same string value are equal—they represent the same domain concept.

typescript
const email1 = Email.create('alice@example.com');
const email2 = Email.create('alice@example.com');

console.log(email1 === email2); // false (different objects)
console.log(email1.equals(email2)); // true (same value)

// In collections, we need custom comparison
const emails = new Set([email1, email2]);
console.log(emails.size); // 2 (Sets use ===, not custom equals)

// Solution: use a map with string keys when deduplicating
const emailMap = new Map();
[email1, email2].forEach(e => emailMap.set(e.value, e));
console.log(emailMap.size); // 1

For immutable value objects, you might implement a custom hash-based collection or store them by their value in databases (use the string value as a unique key, not an auto-increment ID). The point is that identity doesn't matter—the content does.

Persistence and serialization

Value objects need to round-trip to and from databases and JSON APIs. A few patterns work well:

  • Store the value field directly: For Email, store the string. For Money, store amount and currency in separate columns.
  • Reconstruct carefully: Use a private constructor and a static factory that bypasses validation (since data from the database is already trusted).
  • JSON serialization: Provide toJSON() and fromJSON() methods.
typescript
class Email {
  private constructor(readonly value: string) {}

  static create(input: string): Email {
    // Validation-heavy factory
    const normalized = input.trim().toLowerCase();
    if (!/^[^@]+@[^@]+\.[^@]+$/.test(normalized)) {
      throw new Error(`Invalid email: ${input}`);
    }
    return new Email(normalized);
  }

  static fromDatabase(value: string): Email {
    // Trusts the database; validation already happened
    return new Email(value);
  }

  toJSON(): string {
    return this.value;
  }

  static fromJSON(value: string): Email {
    return Email.create(value);
  }
}

This way, deserialization from trusted sources is fast, while deserialization from user input is validated.

Common mistakes

  • Mutable value objects: If you allow fields to be modified after construction, you break the guarantee that the value is valid. Keep fields readonly; if you need a modified copy, return a new instance.
  • Forgetting to override equals(): Without a custom equals() method, two value objects with the same content won't be considered equal. This breaks logic that relies on deduplication or comparison.
  • Validation in setters: If you use a traditional class with getters and setters, you're back to primitive obsession. Value objects are created once, fully validated, then never change.
  • Over-engineering: Not every domain concept needs a value object. Use them for things that have rules, appear in multiple places, or are prone to bugs when left as primitives. A simple age field might not warrant its own class; a date range representing a contract period might.
  • Leaking the value field: If callers reach directly into value.value to bypass validation, you've defeated the purpose. Provide only the operations you want to allow.

Key takeaways

  • Value objects encode validation and business rules into the type system, making invalid states unreachable at compile time and run time.
  • Common examples include Email, Money, DateRange, and PhoneNumber—domains where validation rules exist and errors are costly.
  • Value objects are immutable: create them once, fully validated, then treat them as data. Comparison happens by value, not object identity.
  • Eliminate primitive obsession by wrapping strings, numbers, and dates in purpose-built classes. Type safety and clarity improve dramatically.
  • Persistence is straightforward: store the underlying value(s) in the database and reconstruct the value object on retrieval, bypassing expensive validation on trusted data.
  • Use custom equals() methods to ensure value objects are compared by content. Provide only the operations that make sense in your domain.
  • Start small—one or two value objects in a codebase—and expand where validation rules or comparison logic would otherwise scatter and diverge.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles