Skip to main content

What is a Value Object?

A Value Object is an immutable wrapper around a primitive value (string, number, boolean, or Date) that encapsulates domain behavior and validation. Two Value Objects with the same value are considered equal.
import { ValueObject } from "@woltz/rich-domain";

class Email extends ValueObject<string> {
  getDomain(): string {
    return this.value.split("@")[1];
  }

  isBusinessEmail(): boolean {
    const freeProviders = ["gmail.com", "yahoo.com", "hotmail.com"];
    return !freeProviders.includes(this.getDomain());
  }
}

const email = new Email("john@example.com");
console.log(email.value); // "john@example.com"
console.log(email.getDomain()); // "example.com"
console.log(email.isBusinessEmail()); // true

Key Characteristics

Primitive Values Only

Value Objects wrap a single primitive value: string, number, boolean, or Date.

Immutable

Once created, a Value Object cannot be changed. Use clone() to create new instances.

Equality by Value

Two Value Objects are equal if their primitive values are equal.

Self-Contained

Value Objects contain all the behavior related to the concept they represent.

Creating Value Objects

Value Objects accept only primitive types:
// ✅ Valid primitive types
class Age extends ValueObject<number> {}
class Email extends ValueObject<string> {}
class IsActive extends ValueObject<boolean> {}
class BirthDate extends ValueObject<Date> {}

// ❌ Invalid - cannot use complex objects
class Address extends ValueObject<{ street: string; city: string }> {}
// Error: Value Objects must use primitive types

Basic Usage

class UserId extends ValueObject<string> {}

const userId = new UserId("user-123");

console.log(userId.value); // "user-123"

// Value objects are immutable
userId.value = "user-456"; // ❌ Error: Cannot assign to read only property

Immutability

Value Objects are frozen on creation. To “change” a Value Object, use the clone() method:
class Price extends ValueObject<number> {
  addTax(taxRate: number): Price {
    const newValue = this.value * (1 + taxRate);
    return this.clone(newValue);
  }

  format(): string {
    return new Intl.NumberFormat("en-US", {
      style: "currency",
      currency: "USD",
    }).format(this.value);
  }
}

const price = new Price(99.99);
const priceWithTax = price.addTax(0.08);

console.log(price.value); // 99.99 (unchanged)
console.log(priceWithTax.value); // 107.99 (new instance)
console.log(priceWithTax.format()); // "$107.99"

Equality

Value Objects are compared by their primitive values:
const email1 = new Email("john@example.com");
const email2 = new Email("john@example.com");
const email3 = new Email("jane@example.com");

email1.equals(email2); // true - same value
email1.equals(email3); // false - different value

Validation

Add schema validation to ensure Value Objects are always valid:
import { z } from "zod";
import { ValueObject } from "@woltz/rich-domain";

const emailSchema = z.string().email("Invalid email format");

class Email extends ValueObject<string> {
  protected static validation = {
    schema: emailSchema,
    config: {
      onCreate: true,
      throwOnError: true,
    },
  };

  getDomain(): string {
    return this.value.split("@")[1];
  }
}

// Valid
const email = new Email("john@example.com");

// Invalid - throws ValidationError
const invalid = new Email("not-an-email");
// ValidationError: Invalid email format

Lifecycle Hooks

Value Objects support lifecycle hooks for custom validation and side effects:
import {
  ValueObject,
  VOHooks,
  throwValidationError,
} from "@woltz/rich-domain";

class Age extends ValueObject<number> {
  protected static hooks: VOHooks<number, Age> = {
    onBeforeCreate: (value) => {
      console.log(`Creating age: ${value}`);
    },

    rules: (ageObject) => {
      if (ageObject.value < 0) {
        throwValidationError("value", "Age cannot be negative");
      }
      if (ageObject.value > 150) {
        throwValidationError("value", "Age exceeds maximum limit");
      }
    },
  };

  isAdult(): boolean {
    return this.value >= 18;
  }

  isMinor(): boolean {
    return this.value < 18;
  }
}

const age = new Age(25);
console.log(age.isAdult()); // true

// Invalid - throws ValidationError
const invalidAge = new Age(-5);
// ValidationError: Age cannot be negative

Available Hooks

  • onBeforeCreate: Called before validation, receives the primitive value
  • rules: Custom business rules, receives the ValueObject instance

Common Examples

Email

import { z } from "zod";

const emailSchema = z.string().email();

class Email extends ValueObject<string> {
  protected static validation = {
    schema: emailSchema,
  };

  getDomain(): string {
    return this.value.split("@")[1];
  }

  getUsername(): string {
    return this.value.split("@")[0];
  }

  isBusinessEmail(): boolean {
    const freeProviders = ["gmail.com", "yahoo.com", "hotmail.com"];
    return !freeProviders.includes(this.getDomain());
  }
}

const email = new Email("john.doe@example.com");
console.log(email.getDomain()); // "example.com"
console.log(email.getUsername()); // "john.doe"
console.log(email.isBusinessEmail()); // true

Price

class Price extends ValueObject<number> {
  protected static hooks: VOHooks<number, Price> = {
    rules: (price) => {
      if (price.value < 0) {
        throwValidationError("value", "Price cannot be negative");
      }
    },
  };

  addTax(taxRate: number): Price {
    return this.clone(this.value * (1 + taxRate));
  }

  discount(percentage: number): Price {
    return this.clone(this.value * (1 - percentage / 100));
  }

  format(currency: string = "USD"): string {
    return new Intl.NumberFormat("en-US", {
      style: "currency",
      currency,
    }).format(this.value);
  }
}

const price = new Price(99.99);
const withTax = price.addTax(0.08);
const discounted = price.discount(10);

console.log(price.format()); // "$99.99"
console.log(withTax.format()); // "$107.99"
console.log(discounted.format()); // "$89.99"

Percentage

class Percentage extends ValueObject<number> {
  protected static hooks: VOHooks<number, Percentage> = {
    rules: (percentage) => {
      if (percentage.value < 0 || percentage.value > 100) {
        throwValidationError("value", "Percentage must be between 0 and 100");
      }
    },
  };

  toDecimal(): number {
    return this.value / 100;
  }

  format(): string {
    return `${this.value}%`;
  }

  apply(amount: number): number {
    return amount * this.toDecimal();
  }
}

const discount = new Percentage(15);
console.log(discount.format()); // "15%"
console.log(discount.toDecimal()); // 0.15
console.log(discount.apply(100)); // 15

Slug

import { z } from "zod";

const slugSchema = z.string().regex(/^[a-z0-9-]+$/);

class Slug extends ValueObject<string> {
  protected static validation = {
    schema: slugSchema,
  };

  static fromTitle(title: string): Slug {
    const slug = title
      .toLowerCase()
      .replace(/[^a-z0-9]+/g, "-")
      .replace(/^-|-$/g, "");
    return new Slug(slug);
  }

  toUrl(baseUrl: string): string {
    return `${baseUrl}/${this.value}`;
  }
}

const slug = Slug.fromTitle("Hello World!");
console.log(slug.value); // "hello-world"
console.log(slug.toUrl("https://example.com")); // "https://example.com/hello-world"

Serialization

ValueObjects automatically serialize to their primitive values:
const email = new Email("john@example.com");

const json = email.toJSON();
// "john@example.com" (just the string value)

// In entities, ValueObjects are automatically unwrapped
class User extends Entity<{ id: Id; email: Email }> {}

const user = new User({ id: new Id(), email: new Email("john@example.com") });
const userJson = user.toJSON();
// { id: "...", email: "john@example.com" }