What is a Value Object?
A Value Object is an immutable object that represents a concept by its attributes, not an identity. Two Value Objects with the same attributes are considered equal.
import { ValueObject } from "@woltz/rich-domain" ;
interface MoneyProps {
amount : number ;
currency : string ;
}
class Money extends ValueObject < MoneyProps > {
get amount () {
return this . props . amount ;
}
get currency () {
return this . props . currency ;
}
add ( other : Money ) : Money {
if ( this . currency !== other . currency ) {
throw new Error ( "Cannot add different currencies" );
}
return this . clone ({ amount: this . amount + other . amount });
}
multiply ( factor : number ) : Money {
return this . clone ({ amount: this . amount * factor });
}
format () : string {
return new Intl . NumberFormat ( "en-US" , {
style: "currency" ,
currency: this . currency ,
}). format ( this . amount );
}
}
Key Characteristics
Immutable Once created, a Value Object cannot be changed. Operations return new
instances.
Equality by Value Two Value Objects are equal if all their attributes are equal.
Self-Contained Value Objects contain all the behavior related to the concept they represent.
No Identity Value Objects don’t have an ID. They’re interchangeable if values match.
Creating Value Objects
const price = new Money ({ amount: 99.99 , currency: "USD" });
const tax = new Money ({ amount: 8.5 , currency: "USD" });
// Operations return new instances
const total = price . add ( tax );
console . log ( price . amount ); // 99.99 (unchanged)
console . log ( total . amount ); // 108.49 (new instance)
Immutability
Value Objects are frozen on creation. Attempting to modify them throws an error:
const address = new Address ({
street: "123 Main St" ,
city: "New York" ,
zipCode: "10001" ,
});
// ❌ This throws an error
address . props . city = "Boston" ;
// TypeError: Cannot assign to read only property 'city'
To “change” a Value Object, use the clone() method:
class Address extends ValueObject < AddressProps > {
changeCity ( city : string ) : Address {
return this . clone ({ city });
}
changeZipCode ( zipCode : string ) : Address {
return this . clone ({ zipCode });
}
}
const address = new Address ({
street: "123 Main St" ,
city: "New York" ,
zipCode: "10001" ,
});
const newAddress = address . changeCity ( "Boston" );
console . log ( address . city ); // "New York" (unchanged)
console . log ( newAddress . city ); // "Boston" (new instance)
Equality
Value Objects are compared by their attributes:
const money1 = new Money ({ amount: 100 , currency: "USD" });
const money2 = new Money ({ amount: 100 , currency: "USD" });
const money3 = new Money ({ amount: 100 , currency: "EUR" });
money1 . equals ( money2 ); // true - same values
money1 . equals ( money3 ); // false - different currency
Validation
Add schema validation to ensure Value Objects are always valid:
import { z } from "zod" ;
import { ValueObject , VOValidation } from "@woltz/rich-domain" ;
const emailSchema = z . object ({
value: z . string (). email ( "Invalid email format" ),
});
type EmailProps = z . infer < typeof emailSchema >;
class Email extends ValueObject < EmailProps > {
protected static validation : VOValidation < EmailProps > = {
schema: emailSchema ,
config: {
onCreate: true ,
throwOnError: true ,
},
};
get value () {
return this . props . value ;
}
getDomain () : string {
return this . value . split ( "@" )[ 1 ];
}
}
// Valid
const email = new Email ({ value: "john@example.com" });
// Invalid - throws ValidationError
const invalid = new Email ({ value: "not-an-email" });
Identity Key
When Value Objects are used in collections and need to be tracked for changes, define an identity key:
class TagReference extends ValueObject <{ tagId : string ; name : string }> {
// Single key
static readonly identityKey = "tagId" ;
get tagId () {
return this . props . tagId ;
}
get name () {
return this . props . name ;
}
}
class Like extends ValueObject <{
postId : string ;
userId : string ;
createdAt : Date ;
}> {
// Composite key
static readonly identityKey = [ "postId" , "userId" ];
get postId () {
return this . props . postId ;
}
get userId () {
return this . props . userId ;
}
}
Using identity keys:
const like = new Like ({
postId: "post-123" ,
userId: "user-456" ,
createdAt: new Date (),
});
console . log ( like . hasIdentityKey ()); // true
console . log ( like . getIdentityKey ()); // "post-123:user-456"
Identity keys are used by the change tracking system to detect additions and
removals in collections of Value Objects.
Hooks
Value Objects support lifecycle hooks:
import {
ValueObject ,
VOValidation ,
VOHooks ,
throwValidationError ,
} from "@woltz/rich-domain" ;
class Money extends ValueObject < MoneyProps > {
protected static validation : VOValidation < MoneyProps > = {
schema: moneySchema ,
};
protected static hooks : VOHooks < MoneyProps , Money > = {
onCreate : ( money ) => {
console . log ( `Money created: ${ money . format () } ` );
},
rules : ( money ) => {
if ( money . amount > 1_000_000 ) {
throwValidationError ( "amount" , "Amount exceeds maximum limit" );
}
},
};
// ... methods
}
Serialization
Convert Value Objects to plain objects:
const address = new Address ({
street: "123 Main St" ,
city: "New York" ,
zipCode: "10001" ,
});
const json = address . toJSON ();
// { street: "123 Main St", city: "New York", zipCode: "10001" }
Common Examples
Email
class Email extends ValueObject <{ value : string }> {
protected static validation : VOValidation <{ value : string }> = {
schema: z . object ({ value: z . string (). email () }),
};
get value () {
return this . props . value ;
}
getDomain () : string {
return this . value . split ( "@" )[ 1 ];
}
isBusinessEmail () : boolean {
const freeProviders = [ "gmail.com" , "yahoo.com" , "hotmail.com" ];
return ! freeProviders . includes ( this . getDomain ());
}
}
Address
class Address extends ValueObject <{
street : string ;
city : string ;
state : string ;
zipCode : string ;
country : string ;
}> {
get street () {
return this . props . street ;
}
get city () {
return this . props . city ;
}
get state () {
return this . props . state ;
}
get zipCode () {
return this . props . zipCode ;
}
get country () {
return this . props . country ;
}
format () : string {
return ` ${ this . street } , ${ this . city } , ${ this . state } ${ this . zipCode } , ${ this . country } ` ;
}
isInCountry ( country : string ) : boolean {
return this . country . toLowerCase () === country . toLowerCase ();
}
}
DateRange
class DateRange extends ValueObject <{ start : Date ; end : Date }> {
protected static hooks : VOHooks <{ start : Date ; end : Date }, DateRange > = {
rules : ( range ) => {
if ( range . start > range . end ) {
throwValidationError ( "end" , "End date must be after start date" );
}
},
};
get start () {
return this . props . start ;
}
get end () {
return this . props . end ;
}
getDurationInDays () : number {
const diff = this . end . getTime () - this . start . getTime ();
return Math . ceil ( diff / ( 1000 * 60 * 60 * 24 ));
}
contains ( date : Date ) : boolean {
return date >= this . start && date <= this . end ;
}
overlaps ( other : DateRange ) : boolean {
return this . start <= other . end && this . end >= other . start ;
}
}