@unruly-software/value-object
v2.1.0
Published
Typecript data modelling library for value objects on top of Zod schemas. Define a type once, get runtime validation, a real `class` you can attach methods to, and lossless `JSON.stringify` round-tripping.
Readme
A small TypeScript library for modelling value objects on top of Zod schemas. Define a type once, get runtime validation, a real class you can attach methods to, and lossless JSON.stringify round-tripping — without writing boilerplate.
class Email extends ValueObject.define({
id: 'Email',
schema: () => z.string().email(),
}) {
get domain() {
return this.props.split('@')[1]
}
}
const email = Email.fromJSON('[email protected]')
email.domain // 'example.com'
JSON.stringify({ email }) // '{"email":"[email protected]"}'Contents
- Installation
- Quick Start
- Why This Design
- Core Concepts
- Schema Methods
- Type Inference
- Comparison With Similar Libraries
- API Reference
- License
Installation
npm install @unruly-software/value-object zod
# or
yarn add @unruly-software/value-object zod
# or
pnpm add @unruly-software/value-object zodZod v4 is the only peer dependency.
Quick Start
import { ValueObject } from '@unruly-software/value-object'
import { z } from 'zod'
class Email extends ValueObject.define({
id: 'Email',
schema: () => z.string().email(),
}) {}
// Parse and validate in one step
const email = Email.fromJSON('[email protected]')
email.props // '[email protected]'
email.toJSON() // '[email protected]'
// Invalid input throws a ZodError
Email.fromJSON('not-an-email') // throws
// Use the schema anywhere Zod is accepted
const userSchema = z.object({
name: z.string(),
email: Email.schema(), // accepts a string OR an existing Email instance
})
const user = userSchema.parse({ name: 'Alice', email: '[email protected]' })
user.email instanceof Email // trueWhy This Design
A value object is an object whose identity is defined entirely by its values rather than by reference. Two Email instances holding the same string are interchangeable; two User entities with the same id are not. Martin Fowler's Value Object bliki entry is the canonical short reference; the pattern is also a foundational building block in Domain-Driven Design.
This library exists because TypeScript on its own can't express "this string has been validated as an email." A string type tells you nothing about what's inside it, and interface User { email: string } is a comment, not a guarantee. The result is validation scattered across every layer that touches the data, and bugs that show up far from the boundary that should have rejected them.
The library is built around three deliberate choices:
Parse, don't validate. Following Alexis King's essay of the same name, unvalidated data is parsed once at the boundary into a type that cannot exist unless it has been validated. From that point on, the type system carries the proof — there is no need to re-check inside business logic.
Schemas, not decorators. Validation lives inside a Zod schema rather than in property decorators. That means no reflect-metadata, no experimental compiler flags, full structural type inference, and you can reuse the schema anywhere Zod is accepted (z.object, .parse, form libraries, OpenAPI generators, tRPC, etc.).
Real classes, not plain objects. A schema produces a class you can extends and add methods, getters, and computed properties to — email.domain, money.add(other), address.formatted — keeping behaviour next to the data it operates on. instanceof works, prototype chains are preserved, and ValueObject.extends() lets you derive a more refined subtype (e.g. GoogleEmail extends Email) without losing either.
JSON serialization that just works. Every instance has a toJSON() method, so JSON.stringify(instance) returns the right shape automatically — no instanceToPlain, no manual serialize() step, no decorator metadata to keep in sync. Combined with fromJSON() on the constructor, persisting and rehydrating value objects is a one-liner in each direction. Custom serialization (e.g. encoding { year, month } as "2024-03") is a single toJSON option on the definition.
Core Concepts
Defining a value object
class UserId extends ValueObject.define({
id: 'UserId',
schema: () => z.string().uuid(),
}) {}
class Age extends ValueObject.define({
id: 'Age',
schema: () => z.number().int().min(0).max(150),
}) {}
class Address extends ValueObject.define({
id: 'Address',
schema: () => z.object({
street: z.string().min(1),
city: z.string().min(1),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
country: z.string().default('US'),
}),
}) {
get formatted() {
const { street, city, zipCode, country } = this.props
return `${street}, ${city} ${zipCode}, ${country}`
}
}
const address = Address.fromJSON({
street: '123 Main St',
city: 'Springfield',
zipCode: '12345',
})
address.props.country // 'US' (from default)
address.formatted // '123 Main St, Springfield 12345, US'Custom JSON serialization
Pass a toJSON option to control the wire format. The library handles JSON.stringify automatically — you don't need to call toJSON() yourself.
class YearMonth extends ValueObject.define({
id: 'YearMonth',
schema: () =>
z
.object({ year: z.number().int(), month: z.number().int().min(1).max(12) })
.or(
z
.string()
.regex(/^\d{4}-\d{2}$/)
.transform((str) => {
const [year, month] = str.split('-').map(Number)
return { year, month }
}),
),
toJSON: (v) => `${v.year}-${String(v.month).padStart(2, '0')}`,
}) {}
const ym = YearMonth.fromJSON('2024-03')
ym.props // { year: 2024, month: 3 }
ym.toJSON() // '2024-03'
JSON.stringify({ ym }) // '{"ym":"2024-03"}'Round-tripping is symmetric: YearMonth.fromJSON(JSON.parse(JSON.stringify(ym))) gives you back an equivalent instance.
Composing value objects
Value object schemas compose like any other Zod schema. Nested values are automatically rehydrated into the right class.
class Customer extends ValueObject.define({
id: 'Customer',
schema: () => z.object({
id: UserId.schema(),
email: Email.schema(),
addresses: z.array(Address.schema()).optional(),
}),
}) {}
const customer = Customer.fromJSON({
id: '123e4567-e89b-12d3-a456-426614174000',
email: '[email protected]',
addresses: [{ street: '123 Main St', city: 'Springfield', zipCode: '12345' }],
})
customer.props.id instanceof UserId // true
customer.props.email instanceof Email // true
customer.props.addresses?.[0] instanceof Address // trueStructural equality
Every value object exposes an equals(other) method. Two instances are considered equal when they are of the same type and contain exactly the same data:
- Object keys are compared in any order, recursively.
- Arrays must have the same length and equal elements in order.
- Nested value objects are compared via their own
equals()— overrides cascade all the way down. Datefields are compared by timestamp.
const a = Address.fromJSON({ street: '123 Main St', city: 'Springfield', zipCode: '12345' })
const b = Address.fromJSON({ zipCode: '12345', city: 'Springfield', street: '123 Main St' })
a === b // false — different references
a.equals(b) // true — same data, key order is irrelevant
const c = Address.fromJSON({ street: '123 Main St', city: 'Springfield', zipCode: '54321' })
a.equals(c) // falseYou can override equals() to express domain-specific identity — comparing entities by id, treating emails case-insensitively, ignoring metadata fields, etc. The override is honoured everywhere the value object appears, including when it is nested inside another value object's props.
class User extends ValueObject.define({
id: 'User',
schema: () => z.object({
id: z.string().uuid(),
name: z.string(),
updatedAt: z.string(),
}),
}) {
override equals(other: User): boolean {
if (!(other instanceof User)) return false
return this.props.id === other.props.id
}
}
const id = '123e4567-e89b-12d3-a456-426614174000'
const a = User.fromJSON({ id, name: 'Alice', updatedAt: '2024-01-01' })
const b = User.fromJSON({ id, name: 'Alice Renamed', updatedAt: '2024-12-31' })
a.equals(b) // true — User identity is the id, not the snapshotCloning
clone() returns a duplicate instance by re-parsing props through the underlying Zod schema, so nested objects and arrays are deep-cloned automatically. The returned instance is of the same class — including subclasses created via ValueObject.extends().
const a = Address.fromJSON({ street: '1 Main St', tags: ['home', 'primary'] })
const b = a.clone()
b === a // false — fresh instance
a.equals(b) // true — same data
b.props !== a.props // true — props are deep-cloned, not shared
b.props.tags.push('mutated')
a.props.tags // ['home', 'primary'] — original is untouchedExtending a value object
ValueObject.extends() derives a new class from an existing one and layers a refined schema on top. The prototype chain is preserved, so instanceof and inherited methods continue to work, and the new schema receives the parent's schema as its first argument.
class Animal extends ValueObject.define({
id: 'Animal',
schema: () => z.object({
name: z.string(),
age: z.number().int().nonnegative(),
}),
}) {
get description() {
return `${this.props.name}, age ${this.props.age}`
}
}
class Dog extends ValueObject.extends(Animal, {
id: 'Dog',
schema: (prev) => prev.and(z.object({ breed: z.string() })),
}) {
bark() {
return `${this.props.name} says woof!`
}
}
class Cat extends ValueObject.extends(Animal, {
id: 'Cat',
schema: (prev) => prev.and(z.object({ indoor: z.boolean() })),
}) {
meow() {
return `${this.props.name} says meow!`
}
}
const dog = Dog.fromJSON({ name: 'Rex', age: 3, breed: 'Labrador' })
dog instanceof Dog // true
dog instanceof Animal // true — inheritance is real
dog.description // 'Rex, age 3' — inherited from Animal
dog.bark() // 'Rex says woof!'
const cat = Cat.fromJSON({ name: 'Whiskers', age: 5, indoor: true })
cat instanceof Cat // true
cat instanceof Animal // true
cat.description // 'Whiskers, age 5'
cat.meow() // 'Whiskers says meow!'
Dog.fromJSON({ name: 'Rex', age: 3 } as any) // throws — missing `breed`A type-level guard enforces that the extension's schema output is still assignable to the parent's. A transform that changes the shape (e.g. string → number) won't compile, so a class X extends ValueObject.extends(...) clause cannot accidentally break the Liskov contract.
Discriminated unions
class Circle extends ValueObject.define({
id: 'Circle',
schema: () => z.object({
kind: z.literal('circle'),
radius: z.number().positive(),
}),
}) {
get area() {
return Math.PI * this.props.radius ** 2
}
}
class Square extends ValueObject.define({
id: 'Square',
schema: () => z.object({
kind: z.literal('square'),
side: z.number().positive(),
}),
}) {
get area() {
return this.props.side ** 2
}
}
const Shape = ValueObject.defineUnion('kind', [Circle, Square])
const shape = Shape.fromJSON({ kind: 'circle', radius: 4 })
shape instanceof Circle // true
Shape.isInstance(Circle, shape) // true (with type narrowing)
// Use it inside any other Zod schema
const drawingSchema = z.object({
title: z.string(),
shape: Shape.schema(),
})The discriminator literal is read directly from each member's z.literal(...), so members are passed as a plain array. isInstance narrows by constructor reference — typos become compile errors.
Schema Methods
Each value object exposes three Zod schemas for different boundaries.
| Method | Accepts | Returns | Use for |
| -------------------- | ------------------------ | ---------------------- | ----------------------------------------- |
| schema() | primitive or instance | instance | Most boundaries — the flexible default |
| schemaPrimitive() | primitive only | instance | Forcing a fresh parse from raw input |
| schemaRaw() | primitive only | primitive (validated) | Validation without wrapping (e.g. forms) |
// schema() — accepts both, returns an instance
Email.schema().parse('[email protected]') // Email
Email.schema().parse(existingEmail) // Email (the same instance)
// schemaPrimitive() — only the raw form
Email.schemaPrimitive().parse('[email protected]') // Email
Email.schemaPrimitive().parse(existingEmail) // throws
// schemaRaw() — validate but don't wrap
Email.schemaRaw().parse('[email protected]') // '[email protected]' (string)Type Inference
class Money extends ValueObject.define({
id: 'Money',
schema: () => z.object({
amount: z.number(),
currency: z.enum(['USD', 'EUR', 'GBP']),
}),
toJSON: (v) => `${v.amount} ${v.currency}`,
}) {}
type MoneyProps = ValueObject.inferProps<typeof Money>
// { amount: number; currency: 'USD' | 'EUR' | 'GBP' }
type MoneyJSON = ValueObject.inferJSON<typeof Money>
// string (from the custom toJSON)
type MoneyInput = ValueObject.inferInput<typeof Money>
// { amount: number; currency: 'USD' | 'EUR' | 'GBP' } | MoneyAll three helpers accept either the constructor (typeof Money) or an instance type (Money).
Comparison With Similar Libraries
This library sits in the small intersection of "schema validation" and "class-based domain modelling." A few related options, and how they differ:
| Library | Style | Class instances | Inheritance / refinement | JSON.stringify round-trip |
| -------------------------------------- | --------------------------- | --------------- | ------------------------------------- | ------------------------------------ |
| @unruly-software/value-object | Class on top of Zod | Yes | extends() preserves prototype chain | Built-in via toJSON() |
| zod-class | Class on top of Zod | Yes | .extend({...}) to add fields | No documented toJSON hook |
| Effect Schema | Schema-first with class API | Yes | Schema.Class with getters/methods | Uses explicit encode / decode |
| class-validator + class-transformer | Decorators on classes | Yes | Decorators inherited via extends | Requires instanceToPlain / plainToInstance |
| Valibot | Functional, tree-shakable | No | n/a — plain objects | Plain object out, no methods |
| io-ts | Functional codecs (fp-ts) | No | n/a — combinators only | Plain object out, no methods |
| runtypes | Functional combinators | No | n/a — .withConstraint, .withBrand | Plain object out, no methods |
A few notes on where the trade-offs sit:
- Functional codec libraries (Valibot, io-ts, runtypes) are excellent for pure validation but produce plain objects. There is nowhere natural to attach
email.domain,money.add(), oraddress.formatted— that behaviour ends up in free functions, away from the data. class-validator/class-transformeris the established decorator-based approach. It supports inheritance and rich validation, but it depends onreflect-metadata, requiresexperimentalDecorators, and round-tripping through JSON is a two-step process:instanceToPlainbeforeJSON.stringifyandplainToInstanceafterJSON.parse.zod-classis the closest direct comparison: it also wraps Zod in a class with.extend(...)for adding fields. It is missing a few key features: no customtoJSONoption, no separate schema for primitive input, and the.extend()method creates a new class that doesn't preserve the prototype chain (soinstanceofchecks and inherited methods don't work).- Effect Schema has a powerful
Schema.ClassAPI and integrates with the rest of the Effect ecosystem (equality, hashing, etc.). It uses explicit encode/decode transformations for serialization rather than the implicittoJSON()convention, and brings the Effect runtime as a dependency.
Pick this library if you want the ergonomics of plain TypeScript classes, validated by Zod, that survive JSON.stringify and JSON.parse without any extra ceremony — and you don't want to take on a larger framework to get it.
API Reference
ValueObject.define(options)
Creates a value object class.
| Option | Type | Description |
| ------------- | ------------------------------- | ------------------------------------------------------ |
| id | string | Unique identifier for the value object type |
| schema | () => ZodSchema | Function returning the Zod schema for validation |
| toJSON? | (value) => unknown | Optional custom JSON serializer |
ValueObject.extends(parent, options)
Derives a new value object class from parent. The returned class extends parent directly, so instanceof and inherited methods work.
| Option | Type | Description |
| ------------- | ------------------------------------------ | --------------------------------------------------- |
| id | string | Unique identifier for the new type |
| schema | (parentSchema) => ZodSchema | Builds the new schema on top of the parent's schema |
| toJSON? | (value) => unknown | Optional override; defaults to the parent's toJSON |
The schema's output type must remain assignable to the parent's output type, or the result is a non-constructable error sentinel that fails to compile when used with extends.
ValueObject.defineUnion(discriminator, members)
Creates a discriminated union of value objects. Each member's schema must be a z.object with the discriminator field set to a z.literal(...); the literal value is read directly from the schema.
| Parameter | Type | Description |
| --------------- | ----------------------------------- | ---------------------------------------------------- |
| discriminator | string | Field name used to distinguish members |
| members | readonly ValueObjectClass[] | Array of member classes |
Returns an object with fromJSON(), schema(), and isInstance(ctor, value) methods. isInstance narrows the value to the given constructor's instance type.
Instance members
| Member | Description |
| ----------------- | --------------------------------------------------------------------------------- |
| props | The validated, readonly data |
| toJSON() | JSON-compatible representation (respects custom toJSON option) |
| equals(other) | Structural equality with deep, key-order-independent comparison; override-friendly |
| clone() | Deep-cloned duplicate instance of the same class (re-parses props through the schema) |
Static members
| Member | Description |
| -------------------- | ----------------------------------------------------------------- |
| fromJSON(input) | Parse raw input (or accept an existing instance) and validate |
| schema() | Zod schema accepting primitive or instance, returning instance |
| schemaPrimitive() | Zod schema accepting only primitive input, returning instance |
| schemaRaw() | The raw underlying Zod schema (no instance wrapping) |
Type helpers
| Helper | Resolves to |
| ------------------------------- | ------------------------------------------------------ |
| ValueObject.inferProps<T> | The validated props shape |
| ValueObject.inferJSON<T> | The return type of toJSON() |
| ValueObject.inferInput<T> | The accepted input: schema input or an instance |
License
MIT — see LICENSE.
Changelog
See CHANGELOG.md for release notes.
