value-object.js
Value Object
- objects that matter only as the combination of their
properties. Two value objects with the same values for all their properties are
considered equal.
This library provides a convenient way to define strict, immutable value
objects.
Install
npm install value-object
Defining value objects
Use subclasses to define value objects with type constraints:
const ValueObject = require('value-object')
class Currency extends ValueObject.define({
code: 'string',
name: 'string'
}) {}
class Money extends ValueObject.define({
currency: Currency,
amount: 'number'
}) {}
...or don't use classes, if you prefer:
const Money = ValueObject.define({
amount: 'number',
currency: { code: 'string' }
})
Instantiating value objects
Use the new
keyword, passing values for each property:
const gbp = new Currency({ code: 'GBP', name: 'British Pounds' })
const price = new Money({ currency: gbp, amount: 12.34 })
const other = new Money({ currency: { code: 'USD', name: 'US Dollars' }, amount: 14.56 })
Constraints prevent value objects from being instantiated with invalid property
values.
Unexpected types
Property values with unexpected types are rejected:
> new Currency({ code: 'USD', name: 123 })
Error: Currency was constructed with invalid property values
Expected: { code:string, name:string }
Actual: { code:string, name:number }
name is invalid:
Expected string, was number
Unrecognised properties
Value objects cannot be instantiated with unrecognised properties:
> new Currency({ code: 'NZD', name: 'New Zealand Dollars', colour: 'All black' })
Error: Currency was constructed with invalid property values
Expected: { code:string, name:string }
Actual: { code:string, name:string, colour:string }
colour is invalid:
Property is unexpected
Missing properties
Value objects cannot be instantiated with missing properties (unless they are optional):
> new Money({ amount: 123 })
Error: Money was constructed with invalid property values
Expected: { currency:Currency, amount:number }
Actual: { amount:number }
currency is invalid:
Property is missing
Setting properties to null
Properties can be set to null
:
> new Money({ currency: null, amount: null })
Money { currency: null, amount: null }
Setting properties to undefined
Properties cannot be set to undefined
(unless they are optional):
> new Money({ currency: null, amount: undefined })
Error: Money was constructed with invalid property values
Expected: { currency:Currency, amount:number }
Actual: { currency:null, amount:undefined }
amount is invalid:
Expected number, was undefined
Built-in property types
Properties can be declared with built-in type constraints:
class Manager extends ValueObject.define({
firstName: 'string',
age: 'number',
trained: 'boolean',
subordinates: 'object',
preferences: 'any'
}) {}
string
: expects a value where typeof value === 'string'
number
: expects a value where typeof value === 'number'
boolean
: expects a value where typeof value === 'boolean'
object
: expects a value where typeof value === 'object'
any
: expects any non-null value
Optional properties
Properties declared with ?
can be set to null
or undedined
, or omitted
altogether:
class Options extends ValueObject.define({
age: 'number?',
aliases: 'object?',
colour: 'string?',
checked: 'boolean?'
}) {}
new Options({ age: null, aliases: {}, colour: undefined })
Optional properties can also be declared with ValueObject.optional()
:
class IceCream extends ValueObject.define({
flavours: ValueObject.optional(['string'])
}) {}
new IceCream({ flavours: ['mint', 'chocolate'] })
new IceCream({})
Array properties
Arrays with arbitrary elements can be declared with the type Array
:
class Person extends ValueObject.define({
favouriteThings: Array
}) {}
new Person({ favouriteThings: ['cheese', 69, null] })
Generic array properties
Arrays with value constraints are declared by wrapping the type definition (e.g.
'number'
, Date
) in []
:
class Point extends ValueObject.define({
x: 'number',
y: 'number'
}) {}
class Polygon extends ValueObject.define({
vertices: [Point]
}) {}
new Polygon({
vertices: [
new Point({ x: 1, y: 2 },
new Point({ x: 3, y: 4 }
)]
})
User-defined properties
Custom property types can be defined with ValueObject.definePropertyType()
and
then used later by name in ValueObject.define()
:
ValueObject.definePropertyType('money', () => ({
coerce(value) {
if (typeof value === 'string') {
const parts = value.split(' ')
return { value: { amount: Number(parts[0]), currency: parts[1] } }
}
return { failure: 'Only string values allowed' }
},
areEqual(a, b) {
return a.currency == b.currency && a.amount == b.amount
},
describe() {
return '<money>'
}
}))
class Allowance extends ValueObject.define({ cash: 'money' }) {}
Property constraints are expressed as a function that returns a value with
the following methods:
.coerce(value)
converts an arbitrary value to the final property value.
Expected to return { value }
when converting the property value is successful or { failure }
with a message when converting fails..areEqual(a, b)
returns true
if two instances of the type are considered equal, or false
otherwise..describe()
returns a string used in error messages mentioning the property.
The constraint is used to convert property values from other types according to its
.coerce(value)
method:
> new Allowance({ cash: '123.00 GBP' })
Allowance { cash: { amount: 123, currency: 'GBP' } }
...and its .describe()
method is used in error messages:
> new Allowance({ cash: 666 })
Error: Allowance was constructed with invalid property values
Expected: { cash:<money> }
Actual: { cash:number }
cash is invalid:
Only string values allowed
Equality
Value objects are considered to be equal if their properties are equal. Equality
of two objects is tested by calling valueObject.isEqualTo(otherValueObject)
:
gbp.isEqualTo(new Currency({ code: 'GBP', name: 'British Pounds' }))
gbp.isEqualTo(new Currency({ code: 'EUR', name: 'Euros' }))
const gbpPrice = new Money({ amount: 123, currency: gbp })
const eurPrice = new Money({ amount: 123, currency: eur })
gbpPrice.isEqualTo(eurPrice)
eurPrice.isEqualTo(new Money({ amount: 123, currency: eur }))
Reflection
ValueObject types have a schema
property that allows reflection over
properties and arbitrary metadata associated with each property:
class Product extends ValueObject.define({
name: 'string',
stockLevel: {
type: 'number',
default: 0,
description: 'units in stock'
}
}) {}
> Product.schema.properties.stockLevel
Property {
constraint: Primitive { cast: [Function: Number], name: 'number' },
metadata: { default: 0, description: 'units in stock' },
optional: false }
Creating new value objects from existing value objects
Use with(newAttributes)
to create new value objects, with new values for a
specific set of properties:
const salePrice = price.with({ amount: 12.0 })
salePrice.currency.code
Converting value objects to plain objects
Use toPlainObject()
to create a plain old mutable object from a value object's
property values:
> JSON.stringify(gbp.toPlainObject())
{ "code": "GBP", "name": "British Pounds" }
Any value-object instances will be converted using their schemas. Any objects
that are not value-object instances will be cloned using
JSON.parse(JSON.stringify(object))
by default. Pass in an optional clone
function to override this behaviour:
valueObject.toPlainObject(fancyDeepCloneFunction)
Converting value objects to JSON
Use toJSON()
to create an object with __type__
properties for subsequent
deserialization:
> JSON.stringify(gbp.toJSON())
{ "__type__": "Currency", "code": "GBP", "name": "British Pounds" }
Converting value objects from JSON
Use ValueObject.deserializeForNamespaces()
to create a deserialize function
that can turn the resulting JSON string back into objects
const deserialize = ValueObject.deserializeForNamespaces([{ Currency }])
const gbp2 = deserialize('{"__type__":"Currency","code":"GBP","name":"British Pounds"}')
gbp2.isEqualTo(gbp)
Immutability
Value objects cannot be updated. Use strict mode
to throw errors when attempts to set property values are made.
gbp.code = 'USD'