Value Objects

Immutable objects defined by their attributes, not identity. Used for type safety and validation.

BaseValueObject

All value objects extend BaseValueObject<T> and validate in their create() factory method:

import { BaseValueObject } from '@cosmneo/onion-lasagna/backend/core/onion-layers';
import { InvariantViolationError } from '@cosmneo/onion-lasagna/backend/core/onion-layers';

class Email extends BaseValueObject<string> {
  private static readonly EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

  static create(value: Email['value']): Email {
    if (!Email.EMAIL_REGEX.test(value)) {
      throw new InvariantViolationError({
        message: 'Invalid email format',
        code: 'INVALID_EMAIL',
      });
    }
    return new Email(value);
  }

  get domain(): string {
    return this.value.split('@')[1];
  }
}

Key features:

  • Immutable: Value is set at construction and cannot be changed
  • Self-validating: Validation runs in create() before construction
  • Comparable: Built-in equals() method with deep equality
  • Type inference: Use ClassName['value'] for input type

Built-in Value Objects

The library provides base value objects you can extend:

Text Types

import {
  BaseTextVo,        // Configurable text with static constraints
  BaseShortTextVo,   // 1-100 characters
  BaseMediumTextVo,  // 1-500 characters
  BaseLongTextVo,    // 1-5000 characters
} from '@cosmneo/onion-lasagna/backend/core/onion-layers';

// Create custom text VO with constraints
class ProductName extends BaseTextVo {
  static override defaultMinLength = 1;
  static override defaultMaxLength = 50;
}

class SkuCode extends BaseTextVo {
  static override defaultMinLength = 3;
  static override defaultMaxLength = 20;
  static override defaultPattern = /^[A-Z0-9-]+$/;
}

Info:

Text VOs use new this(value) internally, so subclass instances are created correctly at runtime. However, TypeScript still infers the return type as the base class. For strict type safety, you can override create():

class ProductName extends BaseTextVo {
  static override defaultMinLength = 1;
  static override defaultMaxLength = 50;

  static override create(value: ProductName['value']): ProductName {
    // Let parent validate, then cast to correct type
    BaseTextVo.create.call(this, value);
    return new ProductName(value);
  }
}

Identifiers

import {
  BaseUuidV4Vo,  // UUID v4 format (random)
  BaseUuidV7Vo,  // UUID v7 format (time-ordered)
} from '@cosmneo/onion-lasagna/backend/core/onion-layers';

// Usage
const id = BaseUuidV4Vo.generate();           // Generate new
const parsed = BaseUuidV4Vo.create(uuidStr);  // Validate existing

Contact

import { BaseEmailVo } from '@cosmneo/onion-lasagna/backend/core/onion-layers';

const email = BaseEmailVo.create('user@example.com');

Pagination

import { BasePaginationVo } from '@cosmneo/onion-lasagna/backend/core/onion-layers';

const page = BasePaginationVo.create({ page: 1, pageSize: 20 });

// Properties
page.page;      // 1
page.pageSize;  // 20
page.offset;    // 0 (calculated: (page - 1) * pageSize)

// Custom max page size
class AdminPaginationVo extends BasePaginationVo {
  static override get maxPageSize(): number {
    return 500;
  }
}

Auditing

import {
  BaseAuditByVo,    // createdBy, updatedBy (optional UUIDs)
  BaseAuditOnVo,    // createdAt, updatedAt (with invariant check)
} from '@cosmneo/onion-lasagna/backend/core/onion-layers';

// Create with current timestamp
const auditOn = BaseAuditOnVo.now();

// Create with specific dates
const auditOn = BaseAuditOnVo.create({
  createdAt: new Date('2024-01-01'),
  updatedAt: new Date('2024-01-15'),
});

// Update timestamp (returns new immutable instance)
const updated = auditOn.update();

Note: BaseAuditOnVo enforces that updatedAt cannot be before createdAt.


Creating Custom Value Objects

Simple Identifier

import { v7 } from 'uuid';
import { BaseUuidV7Vo } from '@cosmneo/onion-lasagna/backend/core/onion-layers';

class OrderId extends BaseUuidV7Vo {
  // Override generate() to return correct type
  static override generate(): OrderId {
    return new OrderId(v7());
  }

  // IMPORTANT: Override create() to return correct type
  static override create(value: OrderId['value']): OrderId {
    const validated = BaseUuidV7Vo.create(value);
    return new OrderId(validated.value);
  }
}

Warning:

Always override create() for UUID subclasses. Without this override, OrderId.create(uuid) returns BaseUuidV7Vo instead of OrderId, breaking type safety and instanceof checks.

Value Object with Custom Validation

import { BaseValueObject } from '@cosmneo/onion-lasagna/backend/core/onion-layers';
import { InvariantViolationError } from '@cosmneo/onion-lasagna/backend/core/onion-layers';

class PhoneNumber extends BaseValueObject<string> {
  private static readonly PHONE_REGEX = /^\+[1-9]\d{1,14}$/;

  static create(value: PhoneNumber['value']): PhoneNumber {
    if (!PhoneNumber.PHONE_REGEX.test(value)) {
      throw new InvariantViolationError({
        message: 'Invalid phone number format (E.164 required)',
        code: 'INVALID_PHONE',
      });
    }
    return new PhoneNumber(value);
  }

  get countryCode(): string {
    return this.value.slice(1, 3);
  }
}

Composite Value Object

import { BaseValueObject } from '@cosmneo/onion-lasagna/backend/core/onion-layers';
import { InvariantViolationError } from '@cosmneo/onion-lasagna/backend/core/onion-layers';

interface AddressData {
  street: string;
  city: string;
  postalCode: string;
  country: string;
}

class Address extends BaseValueObject<AddressData> {
  static create(data: Address['value']): Address {
    if (!data.street || data.street.trim().length === 0) {
      throw new InvariantViolationError({
        message: 'Street is required',
        code: 'INVALID_ADDRESS',
      });
    }
    if (!data.country || data.country.length !== 2) {
      throw new InvariantViolationError({
        message: 'Country must be 2-letter ISO code',
        code: 'INVALID_COUNTRY',
      });
    }
    return new Address(data);
  }

  get street(): string { return this.value.street; }
  get city(): string { return this.value.city; }
  get fullAddress(): string {
    return `${this.value.street}, ${this.value.city}, ${this.value.country}`;
  }
}

Enum-like Value Object

import { BaseValueObject } from '@cosmneo/onion-lasagna/backend/core/onion-layers';

type OrderStatusValue = 'pending' | 'confirmed' | 'shipped' | 'cancelled';

class OrderStatus extends BaseValueObject<OrderStatusValue> {
  static pending(): OrderStatus {
    return new OrderStatus('pending');
  }

  static confirmed(): OrderStatus {
    return new OrderStatus('confirmed');
  }

  static shipped(): OrderStatus {
    return new OrderStatus('shipped');
  }

  static cancelled(): OrderStatus {
    return new OrderStatus('cancelled');
  }

  isPending(): boolean { return this.value === 'pending'; }
  isShipped(): boolean { return this.value === 'shipped'; }
  isModifiable(): boolean {
    return this.isPending() || this.value === 'confirmed';
  }
}

Equality Comparison

BaseValueObject provides deep equality via equals():

const email1 = BaseEmailVo.create('user@example.com');
const email2 = BaseEmailVo.create('user@example.com');
const email3 = BaseEmailVo.create('other@example.com');

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

The comparison handles:

  • Primitive values
  • Nested objects
  • Arrays
  • Date objects

Rules

  • Use factory methods (create, generate) - never call constructor directly
  • Validate in create() and throw InvariantViolationError for invalid data
  • Use ClassName['value'] for type inference in create parameters
  • Add convenience getters for derived values
  • Extend built-in VOs when possible (BaseEmailVo, BaseUuidV7Vo, etc.)
  • Don't mutate after construction
  • Don't use for complex objects (use Entities/Aggregates)