Skip to content

unruly-software/value-object

Repository files navigation

@unruly-software/value-object

Build Status npm version Coverage Status License: MIT Node.js LTS TypeScript

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('alice@example.com')
email.domain                // 'example.com'
JSON.stringify({ email })   // '{"email":"alice@example.com"}'

Contents

Installation

npm install @unruly-software/value-object zod
# or
yarn add @unruly-software/value-object zod
# or
pnpm add @unruly-software/value-object zod

Zod 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('user@example.com')
email.props          // 'user@example.com'
email.toJSON()       // 'user@example.com'

// 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: 'alice@example.com' })
user.email instanceof Email // true

Why 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: 'alice@example.com',
  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 // true

Structural 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.
  • Date fields 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)    // false

You 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 snapshot

Cloning

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 untouched

Extending 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('a@b.com')              // Email
Email.schema().parse(existingEmail)          // Email (the same instance)

// schemaPrimitive() — only the raw form
Email.schemaPrimitive().parse('a@b.com')     // Email
Email.schemaPrimitive().parse(existingEmail) // throws

// schemaRaw() — validate but don't wrap
Email.schemaRaw().parse('a@b.com')           // 'a@b.com' (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' } | Money

All 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(), or address.formatted — that behaviour ends up in free functions, away from the data.
  • class-validator / class-transformer is the established decorator-based approach. It supports inheritance and rich validation, but it depends on reflect-metadata, requires experimentalDecorators, and round-tripping through JSON is a two-step process: instanceToPlain before JSON.stringify and plainToInstance after JSON.parse.
  • zod-class is the closest direct comparison: it also wraps Zod in a class with .extend(...) for adding fields. It is missing a few key features: no custom toJSON option, no separate schema for primitive input, and the .extend() method creates a new class that doesn't preserve the prototype chain (so instanceof checks and inherited methods don't work).
  • Effect Schema has a powerful Schema.Class API and integrates with the rest of the Effect ecosystem (equality, hashing, etc.). It uses explicit encode/decode transformations for serialization rather than the implicit toJSON() 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.

About

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.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors