From 1babc3bcbe8be49ca385e582072e7d8d16074411 Mon Sep 17 00:00:00 2001 From: James Apple Date: Wed, 8 Apr 2026 03:11:32 +1000 Subject: [PATCH 1/7] Add value-object union type constraint --- src/union.ts | 62 +++++++++++++++++++++++++++++++++++------ src/value-object.ts | 55 ++++++++++++++++++++++++++++++++++-- test/edge-cases.test.ts | 31 +++++++++++++++++---- 3 files changed, 133 insertions(+), 15 deletions(-) diff --git a/src/union.ts b/src/union.ts index 99d624c..2f79c5f 100644 --- a/src/union.ts +++ b/src/union.ts @@ -1,10 +1,11 @@ import z from 'zod' -import { ValueObjectConstructor, ValueObjectInstance } from './value-object' +import {ValueObjectConstructor, ValueObjectInstance} from './value-object' import { extractSchema, extractZodLiteralValueFromObjectSchema, once, } from './utils' + export type ValueObjectSchema = T extends ValueObjectConstructor< string, infer Z, @@ -36,6 +37,28 @@ export type UnionOutput< [K in keyof T]: ValueObjectInst }[keyof T] +type DiscriminatorOf = C extends ValueObjectConstructor< + string, + infer Z, + any +> + ? z.output extends {[P in D]: infer V} + ? V + : never + : never + +type ValidatedUnionMembers< + D extends string, + T extends Record>, +> = { + [K in keyof T]: K extends DiscriminatorOf + ? unknown + : { + DISCRIMINATOR_MISMATCH: `Schema discriminator literal does not match key "${K & + string}"` + } + } + export interface ValueObjectUnion< T extends Record>, > { @@ -49,17 +72,40 @@ export interface ValueObjectUnion< fromJSON(input: UnionInput): UnionOutput } +/** + * Creates a discriminated union of value objects. Each member must use a + * `z.literal()` for the discriminator field, and the literal value must match + * the key in the values record. + * + * @example + * class Dog extends ValueObject.define({ + * id: 'Dog', + * schema: () => z.object({ type: z.literal('dog'), woofs: z.boolean() }), + * }) {} + * + * class Cat extends ValueObject.define({ + * id: 'Cat', + * schema: () => z.object({ type: z.literal('cat'), sharpClaws: z.boolean() }), + * }) {} + * + * const Pets = ValueObject.defineUnion('type', () => ({ dog: Dog, cat: Cat })) + * + * const pet = Pets.fromJSON({ type: 'dog', woofs: true }) // Dog | Cat + * if (Pets.isInstance('dog', pet)) pet.props.woofs + */ export function defineUnion< D extends string, T extends Record>, ->(discriminator: D, values: () => T): ValueObjectUnion { +>( + discriminator: D, + values: () => T & ValidatedUnionMembers, +): ValueObjectUnion { const getValues = once(values) const validate = once(() => { Object.entries(getValues()).forEach(([discriminatorValue, ctor]) => { const schema = extractSchema(ctor) - /** Ideally this would be enforced by the type system. */ const instanceDiscriminator = extractZodLiteralValueFromObjectSchema( schema, discriminator, @@ -84,11 +130,11 @@ export function defineUnion< types.length === 1 ? z.literal(types[0]) : z.union( - types.map((type) => z.literal(type)) as [ - z.ZodLiteral, - ...z.ZodLiteral[], - ], - ) + types.map((type) => z.literal(type)) as [ + z.ZodLiteral, + ...z.ZodLiteral[], + ], + ) return z .object({ diff --git a/src/value-object.ts b/src/value-object.ts index 0dfd84c..00d31d0 100644 --- a/src/value-object.ts +++ b/src/value-object.ts @@ -8,6 +8,12 @@ import { ValueObjectIdSymbol, } from './utils' +/** + * Infers the serialized JSON shape of a value object (the return type of `toJSON()`). + * + * @example + * type EmailJSON = ValueObject.inferJSON // string + */ export type inferJSON = T extends ValueObjectConstructor< string, any, @@ -18,6 +24,12 @@ export type inferJSON = T extends ValueObjectConstructor< ? JS : never +/** + * Infers the parsed `props` shape of a value object (the schema's output type). + * + * @example + * type YearMonthProps = ValueObject.inferProps // { year: number, month: number } + */ export type inferProps = T extends ValueObjectConstructor< string, infer Z, @@ -28,6 +40,12 @@ export type inferProps = T extends ValueObjectConstructor< ? z.output : never +/** + * Infers the accepted input type of a value object — either the raw schema input or an existing instance. + * + * @example + * type EmailInput = ValueObject.inferInput // string | Email + */ export type inferInput = T extends ValueObjectConstructor< string, infer Z, @@ -38,6 +56,16 @@ export type inferInput = T extends ValueObjectConstructor< ? z.input | T : never +export type inferRawInput = T extends ValueObjectConstructor< + string, + infer Z, + any +> + ? z.input + : T extends ValueObjectInstance + ? z.input + : never + export interface ValueObjectInstance< ID extends string, T extends z.ZodTypeAny, @@ -79,9 +107,32 @@ export interface ValueObjectConstructor< props: z.input | InstanceType, ): InstanceType - new (props: z.output): ValueObjectInstance + new(props: z.output): ValueObjectInstance } +/** + * Creates a value object class backed by a Zod schema. Extend the returned class + * to add methods/getters. The class exposes `fromJSON`, `schema`, `schemaPrimitive` + * and `schemaRaw` statics, plus `props` and `toJSON()` on instances. + * + * @example + * class Email extends ValueObject.define({ + * id: 'Email', + * schema: () => z.string().email(), + * }) {} + * + * const email = Email.fromJSON('value@object.com') + * email.props // 'value@object.com' + * email.toJSON() // 'value@object.com' + * + * @example + * // With a custom JSON serializer: + * class YearMonth extends ValueObject.define({ + * id: 'YearMonth', + * schema: () => z.object({ year: z.number(), month: z.number() }), + * toJSON: (v) => `${v.year}-${String(v.month).padStart(2, '0')}`, + * }) {} + */ export function define< ID extends string, T extends z.ZodTypeAny, @@ -91,7 +142,7 @@ export function define< schema: () => T toJSON?: (value: z.output) => JS }): ValueObjectConstructor { - const { id } = options + const {id} = options const getSchema = once(options.schema) const schema = once(function (klass: ValueObjectConstructor) { diff --git a/test/edge-cases.test.ts b/test/edge-cases.test.ts index 4321724..728b4e2 100644 --- a/test/edge-cases.test.ts +++ b/test/edge-cases.test.ts @@ -102,14 +102,34 @@ describe('Edge Cases and Error Handling', () => { }) }) {} - it('should throw error for mismatched discriminator values', () => { - // TODO: Can we make this a type error? - const MismatchedUnion = ValueObject.defineUnion('type', () => ({ + it('should reject mismatched discriminator values at the type level', () => { + ValueObject.defineUnion('type', () => ({ + // @ts-expect-error - Dog's discriminator literal is "dog", not "wrongKey" wrongKey: Dog, - cat: Cat + cat: Cat, })) - expect(() => MismatchedUnion.fromJSON({type: 'dog', woofs: true})) + ValueObject.defineUnion('type', () => ({ + // @ts-expect-error - Cat's discriminator literal is "cat", not "dog" + dog: Cat, + // @ts-expect-error - Dog's discriminator literal is "dog", not "cat" + cat: Dog, + })) + + ValueObject.defineUnion('type', () => ({ + // @ts-expect-error - "spaghetti" is not a valid discriminator for Dog + spaghetti: Dog, + // @ts-expect-error - "bolognese" is not a valid discriminator for Cat + bolognese: Cat, + })) + + // Runtime guard still rejects mismatches that bypass the type system. + const MismatchedUnion = ValueObject.defineUnion('type', () => ({ + wrongKey: Dog, + cat: Cat, + }) as any) + + expect(() => MismatchedUnion.fromJSON({type: 'dog', woofs: true} as any)) .toThrow('Discriminator value mismatch for Dog: expected "wrongKey", got "dog"') }) @@ -177,6 +197,7 @@ describe('Edge Cases and Error Handling', () => { expect(() => { const BadUnion = ValueObject.defineUnion('type', () => ({ + // @ts-expect-error - StringVO's schema is not an object with a discriminator field string: StringVO })) BadUnion.fromJSON('test') From 17bd76ee33bff5f322c3294692923457916fb401 Mon Sep 17 00:00:00 2001 From: James Apple Date: Wed, 8 Apr 2026 12:59:17 +1000 Subject: [PATCH 2/7] Union, extends --- README.md | 606 +++++++++++++++++++------------------------ src/bundle.ts | 1 + src/union.ts | 28 +- src/value-object.ts | 154 ++++++++++- test/extends.test.ts | 457 ++++++++++++++++++++++++++++++++ 5 files changed, 898 insertions(+), 348 deletions(-) create mode 100644 test/extends.test.ts diff --git a/README.md b/README.md index 7fa7706..0eeb93f 100644 --- a/README.md +++ b/README.md @@ -13,150 +13,170 @@ -A TypeScript library for creating type-safe value objects, DTOs or entities with -[Zod](https://zod.dev/) schema validation. Build robust domain models with -compile-time type safety, runtime validation, and seamless JSON serialization. +A small TypeScript library for modelling [value objects](https://martinfowler.com/bliki/ValueObject.html) on top of [Zod](https://zod.dev/) 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. -This library is the logical evolution of the [Parse, don't -validate](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/) -philosophy. Instead of validating raw data and passing that data around, we -nominally type it to show the data is guaranteed to be valid. - -If you ever wondered whether the "email: string" in your interfaces is actually -a valid email this is the library for you. +```typescript +class Email extends ValueObject.define({ + id: 'Email', + schema: () => z.string().email(), +}) { + get domain() { + return this.props.split('@')[1] + } +} -## Features +const email = Email.fromJSON('alice@example.com') +email.domain // 'example.com' +JSON.stringify({ email }) // '{"email":"alice@example.com"}' +``` -- **Complete Zod support**: Use any Zod schema to define the properties of your value objects. You can use `ValueObject.schema()` to integrate the value object with library of your choice. -- **1 Peer Dependency**: Only peer dependency on Zod (which you're probably already using) -- **Type-Safe Value Objects**: Leverage TypeScript's type system with automatic type inference from Zod schemas -- **Runtime Validation**: Built on Zod for robust schema validation and transformation -- **Discriminated Unions**: Create type-safe unions of value objects with automatic type narrowing -- **JSON Serialization**: Automatic JSON serialization with custom transformation and typed support -- **Nested Value Objects**: Compose complex domain models from simple value objects -- **100% Typesafe**: All operations are fully typed with no `any` or type assertions required +## Contents + +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Why This Design](#why-this-design) +- [Core Concepts](#core-concepts) + - [Defining a value object](#defining-a-value-object) + - [Custom JSON serialization](#custom-json-serialization) + - [Composing value objects](#composing-value-objects) + - [Extending a value object](#extending-a-value-object) + - [Discriminated unions](#discriminated-unions) +- [Schema Methods](#schema-methods) +- [Type Inference](#type-inference) +- [Comparison With Similar Libraries](#comparison-with-similar-libraries) +- [API Reference](#api-reference) +- [License](#license) ## Installation ```bash -# npm npm install @unruly-software/value-object zod - -# yarn +# or yarn add @unruly-software/value-object zod - -# pnpm +# or pnpm add @unruly-software/value-object zod ``` +Zod v4 is the only peer dependency. + ## Quick Start ```typescript import { ValueObject } from '@unruly-software/value-object' import { z } from 'zod' -// 1. Define a simple value object for email validation class Email extends ValueObject.define({ id: 'Email', - schema: () => z.string().email() + schema: () => z.string().email(), }) {} -// 2. Create and validate instances +// Parse and validate in one step const email = Email.fromJSON('user@example.com') -console.log(email.props) // 'user@example.com' -console.log(email.toJSON()) // 'user@example.com' - -// 3. Automatic validation -try { - Email.fromJSON('invalid-email') // Throws ZodError -} catch (error) { - console.log('Validation failed!') -} +email.props // 'user@example.com' +email.toJSON() // 'user@example.com' + +// Invalid input throws a ZodError +Email.fromJSON('not-an-email') // throws -// 4. Use in Zod schemas for forms and APIs +// Use the schema anywhere Zod is accepted const userSchema = z.object({ - email: Email.schema(), // Accepts both strings and Email instances - name: z.string() + name: z.string(), + email: Email.schema(), // accepts a string OR an existing Email instance }) -const user = userSchema.parse({ - email: 'user@example.com', // Automatically creates Email instance - name: 'John Doe' -}) -console.log(user.email instanceof Email) // true +const user = userSchema.parse({ name: 'Alice', email: 'alice@example.com' }) +user.email instanceof Email // true ``` -## Examples +## 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](https://martinfowler.com/bliki/ValueObject.html) 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. -### Basic Value Objects +The library is built around three deliberate choices: + +**Parse, don't validate.** Following Alexis King's [essay of the same name](https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/), 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 ```typescript -// Simple string-based value object class UserId extends ValueObject.define({ id: 'UserId', - schema: () => z.string().uuid() + schema: () => z.string().uuid(), }) {} -// Number-based value object with validation class Age extends ValueObject.define({ id: 'Age', - schema: () => z.number().int().min(0).max(150) + schema: () => z.number().int().min(0).max(150), }) {} -const userId = UserId.fromJSON('123e4567-e89b-12d3-a456-426614174000') -const age = Age.fromJSON(25) -``` - -### Object-Based Value Objects - -```typescript -// Complex value object with multiple properties 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') - }) -}) {} + 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: 'Anytown', - zipCode: '12345' + city: 'Springfield', + zipCode: '12345', }) -console.log(address.props.country) // 'US' (from default) -console.log(address.toJSON()) // Full address object +address.props.country // 'US' (from default) +address.formatted // '123 Main St, Springfield 12345, US' ``` -### Custom JSON Serialization +### 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. ```typescript -// Value object with custom serialization format class YearMonth extends ValueObject.define({ id: 'YearMonth', - schema: () => z.object({ - year: z.number().int().min(1900), - 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: (value) => `${value.year}-${value.month.toString().padStart(2, '0')}` + 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')}`, }) {} -// Can create from object or string -const ym1 = YearMonth.fromJSON({ year: 2024, month: 3 }) -const ym2 = YearMonth.fromJSON('2024-03') - -console.log(ym1.toJSON()) // '2024-03' -console.log(ym2.toJSON()) // '2024-03' -console.log(ym1.props) // { year: 2024, month: 3 } +const ym = YearMonth.fromJSON('2024-03') +ym.props // { year: 2024, month: 3 } +ym.toJSON() // '2024-03' +JSON.stringify({ ym }) // '{"ym":"2024-03"}' ``` -### Nested Value Objects +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. ```typescript class Customer extends ValueObject.define({ @@ -164,331 +184,253 @@ class Customer extends ValueObject.define({ schema: () => z.object({ id: UserId.schema(), email: Email.schema(), - addresses: z.array(Address.schema()).optional() - }) + addresses: z.array(Address.schema()).optional(), + }), }) {} -// Seamlessly compose value objects const customer = Customer.fromJSON({ id: '123e4567-e89b-12d3-a456-426614174000', - email: 'customer@example.com', - addresses: [{ - street: '123 Main St', - city: 'Anytown', - zipCode: '12345' - }] + email: 'alice@example.com', + addresses: [{ street: '123 Main St', city: 'Springfield', zipCode: '12345' }], }) -// All nested objects are automatically converted to value objects -console.log(customer.props.id instanceof UserId) // true -console.log(customer.props.email instanceof Email) // true -console.log(customer.props.addresses?.[0] instanceof Address) // true +customer.props.id instanceof UserId // true +customer.props.email instanceof Email // true +customer.props.addresses?.[0] instanceof Address // true ``` -### Value Object Unions +### 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. ```typescript -// Individual value objects with discriminator -class Dog extends ValueObject.define({ - id: 'Dog', +class Animal extends ValueObject.define({ + id: 'Animal', schema: () => z.object({ - type: z.literal('dog'), - breed: z.string(), - woofs: z.boolean() - }) -}) {} + name: z.string(), + age: z.number().int().nonnegative(), + }), +}) { + get description() { + return `${this.props.name}, age ${this.props.age}` + } +} -class Cat extends ValueObject.define({ - id: 'Cat', - schema: () => z.object({ - type: z.literal('cat'), - breed: z.string(), - purrs: z.boolean() - }) -}) {} +class Dog extends ValueObject.extends(Animal, { + id: 'Dog', + schema: (prev) => prev.and(z.object({ breed: z.string() })), +}) { + bark() { + return `${this.props.name} says woof!` + } +} -// Create a discriminated union -const Pet = ValueObject.defineUnion('type', () => ({ - dog: Dog, - cat: Cat -})) +class Cat extends ValueObject.extends(Animal, { + id: 'Cat', + schema: (prev) => prev.and(z.object({ indoor: z.boolean() })), +}) { + meow() { + return `${this.props.name} says meow!` + } +} -// Type-safe parsing and narrowing -const myPet = Pet.fromJSON({ - type: 'dog', - breed: 'Golden Retriever', - woofs: true -}) +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!' -console.log(myPet instanceof Dog) // true -console.log(Pet.isInstance('dog', myPet)) // true -console.log(Pet.isInstance('cat', myPet)) // false +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!' -// Use in schemas for automatic type narrowing -const petOwnerSchema = z.object({ - name: z.string(), - pet: Pet.schema() -}) +Dog.fromJSON({ name: 'Rex', age: 3 } as any) // throws — missing `breed` ``` -## Type Access and Inference +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. -ValueObject provides utility types to extract TypeScript types from your value objects: +### Discriminated unions ```typescript -class Money extends ValueObject.define({ - id: 'Money', +class Circle extends ValueObject.define({ + id: 'Circle', schema: () => z.object({ - amount: z.number(), - currency: z.enum(['USD', 'EUR', 'GBP']) + kind: z.literal('circle'), + radius: z.number().positive(), }), - toJSON: (value) => `${value.amount} ${value.currency}` -}) {} - -// Extract types from value objects -type MoneyProps = ValueObject.inferProps // { amount: number, currency: 'USD' | 'EUR' | 'GBP' } -type MoneyJSON = ValueObject.inferJSON // string (due to custom toJSON) -type MoneyInput = ValueObject.inferInput // { amount: number, currency: 'USD' | 'EUR' | 'GBP' } | Money - -// Also works with constructor types -type MoneyProps2 = ValueObject.inferProps // Same as above -type MoneyJSON2 = ValueObject.inferJSON // Same as above -type MoneyInput2 = ValueObject.inferInput // Same as above - -// Use in function signatures -function processPayment(amount: Money, balance: Money): Money { - // ...whatever processing logic - if (balance.has(amount)) { - return balance.minus(amount) +}) { + get area() { + return Math.PI * this.props.radius ** 2 } } -``` - -## Schema Methods - -Each value object provides three different schema access methods for different use cases: -### `schema()` - Union Schema (Most Common) +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 + } +} -```typescript -const emailSchema = Email.schema() +const Shape = ValueObject.defineUnion('kind', () => ({ + circle: Circle, + square: Square, +})) -// Accepts both primitives and existing instances -emailSchema.parse('user@example.com') // Creates new Email instance -emailSchema.parse(existingEmail) // Returns the same instance +const shape = Shape.fromJSON({ kind: 'circle', radius: 4 }) +shape instanceof Circle // true +Shape.isInstance('circle', shape) // true (with type narrowing) -// Perfect for API boundaries and form validation -const apiSchema = z.object({ - userEmail: Email.schema(), // Flexible input, guaranteed Email output - userId: UserId.schema() +// Use it inside any other Zod schema +const drawingSchema = z.object({ + title: z.string(), + shape: Shape.schema(), }) ``` -### `schemaPrimitive()` - Transform Only - -```typescript -const emailPrimitiveSchema = Email.schemaPrimitive() +The discriminator literal on each member is checked against the key at the type level — keying `Circle` under anything other than `'circle'` is a compile-time error. -// Only accepts primitive values, always creates new instances -emailPrimitiveSchema.parse('user@example.com') // ✅ Creates Email -emailPrimitiveSchema.parse(existingEmail) // ❌ Throws error +## Schema Methods -// Useful when you want to ensure fresh instances -const userCreationSchema = z.object({ - email: Email.schemaPrimitive() // Only accepts string input -}) -``` +Each value object exposes three Zod schemas for different boundaries. -### `schemaRaw()` - Raw Validation Only +| 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) | ```typescript -const emailRawSchema = Email.schemaRaw() +// schema() — accepts both, returns an instance +Email.schema().parse('a@b.com') // Email +Email.schema().parse(existingEmail) // Email (the same instance) -// Returns validated primitives without wrapping in value objects -emailRawSchema.parse('user@example.com') // Returns: 'user@example.com' (string) +// schemaPrimitive() — only the raw form +Email.schemaPrimitive().parse('a@b.com') // Email +Email.schemaPrimitive().parse(existingEmail) // throws -// Useful for validation without instantiation such as in forms. -function validateEmailFormat(input: unknown): string { - return emailRawSchema.parse(input) // Just validation, no wrapping -} +// schemaRaw() — validate but don't wrap +Email.schemaRaw().parse('a@b.com') // 'a@b.com' (string) ``` -## API Reference - -### `ValueObject.define(options)` - -Creates a new value object class. - -**Parameters:** -- `options.id` (string): Unique identifier for the value object type -- `options.schema` (function): Function returning a Zod schema for validation -- `options.toJSON` (function, optional): Custom JSON serialization function - -**Returns:** Value object constructor class +## Type Inference ```typescript -class Example extends ValueObject.define({ - id: 'Example', - schema: () => z.string(), - toJSON: (value) => value.toUpperCase() // optional +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}`, }) {} -``` - -### `ValueObject.defineUnion(discriminator, values)` - -Creates a discriminated union of value objects. -**Parameters:** -- `discriminator` (string): Field name used to distinguish between types -- `values` (function): Function returning object mapping discriminator values to value object classes +type MoneyProps = ValueObject.inferProps +// { amount: number; currency: 'USD' | 'EUR' | 'GBP' } -**Returns:** Value object union with `fromJSON()`, `schema()`, and `isInstance()` methods +type MoneyJSON = ValueObject.inferJSON +// string (from the custom toJSON) -```typescript -const Union = ValueObject.defineUnion('type', () => ({ - typeA: ClassA, - typeB: ClassB -})) +type MoneyInput = ValueObject.inferInput +// { amount: number; currency: 'USD' | 'EUR' | 'GBP' } | Money ``` -### Instance Methods - -#### `valueObject.toJSON()` - -Serializes the value object to JSON-compatible format. - -**Returns:** JSON representation (respects custom `toJSON` option) - -#### `valueObject.props` - -**Returns:** The validated data properties (readonly) - -### Static Methods +All three helpers accept either the constructor (`typeof Money`) or an instance type (`Money`). -#### `ValueObjectClass.fromJSON(input)` +## Comparison With Similar Libraries -Creates an instance from JSON input with validation. +This library sits in the small intersection of "schema validation" and "class-based domain modelling." A few related options, and how they differ: -**Parameters:** -- `input`: Raw data or existing instance +| 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](https://github.com/sam-goodwin/zod-class) | Class on top of Zod | Yes | `.extend({...})` to add fields | No documented `toJSON` hook | +| [Effect Schema](https://effect.website/docs/schema/classes/) | Schema-first with class API | Yes | `Schema.Class` with getters/methods | Uses explicit `encode` / `decode` | +| [class-validator](https://github.com/typestack/class-validator) + [class-transformer](https://github.com/typestack/class-transformer) | Decorators on classes | Yes | Decorators inherited via `extends` | Requires `instanceToPlain` / `plainToInstance` | +| [Valibot](https://valibot.dev) | Functional, tree-shakable | No | n/a — plain objects | Plain object out, no methods | +| [io-ts](https://github.com/gcanti/io-ts) | Functional codecs (`fp-ts`) | No | n/a — combinators only | Plain object out, no methods | +| [runtypes](https://github.com/runtypes/runtypes) | Functional combinators | No | n/a — `.withConstraint`, `.withBrand` | Plain object out, no methods | +| [type-fest `Tagged`](https://github.com/sindresorhus/type-fest) | Type-level brand only | No | n/a — types only | Trivial — value is the primitive | -**Returns:** Value object instance +A few notes on where the trade-offs sit: -#### `ValueObjectClass.schema()` +- **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. +- **`type-fest`-style branding** is the lightest possible option but provides no runtime validation; you're responsible for parsing the value into the branded type yourself. +- **`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. -**Returns:** Zod schema that accepts both primitives and instances +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. -#### `ValueObjectClass.schemaPrimitive()` - -**Returns:** Zod schema that only accepts primitives and transforms to instances - -#### `ValueObjectClass.schemaRaw()` - -**Returns:** Raw Zod schema for validation only - -### Union Methods - -#### `union.fromJSON(input)` +## API Reference -Parses input and returns the appropriate value object instance. +### `ValueObject.define(options)` -#### `union.schema()` +Creates a value object class. -**Returns:** Zod schema for the union +| 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 | -#### `union.isInstance(discriminatorValue, value)` +### `ValueObject.extends(parent, options)` -Type guard to check if value is instance of specific union member. +Derives a new value object class from `parent`. The returned class extends `parent` directly, so `instanceof` and inherited methods work. -**Parameters:** -- `discriminatorValue`: The discriminator value to check -- `value`: Value to test +| 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` | -**Returns:** Boolean (with type narrowing) +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`. -## Common Patterns +### `ValueObject.defineUnion(discriminator, members)` -### Domain-Driven Design +Creates a discriminated union of value objects. -```typescript -// Money value object for financial calculations -class Money extends ValueObject.define({ - id: 'Money', - schema: () => z.object({ - amount: z.number().nonnegative(), - currency: z.enum(['USD', 'EUR', 'GBP']) - }) -}) { - add(other: Money): Money { - if (this.props.currency !== other.props.currency) { - throw new Error('Cannot add money with different currencies') - } - - return new Money({ - amount: this.props.amount + other.props.amount, - currency: this.props.currency - }) - } +| Parameter | Type | Description | +| --------------- | ------------------------------------------ | -------------------------------------------------- | +| `discriminator` | `string` | Field name used to distinguish members | +| `members` | `() => Record` | Map of discriminator literal → member class | - multiply(factor: number): Money { - return new Money({ - amount: Math.round(this.props.amount * factor * 100) / 100, - currency: this.props.currency - }) - } -} +Returns an object with `fromJSON()`, `schema()`, and `isInstance()` methods. -// Order aggregate with business logic -class Order extends ValueObject.define({ - id: 'Order', - schema: () => z.object({ - id: z.string(), - items: z.array(z.object({ - price: Money.schema(), - quantity: z.number().int().positive() - })), - tax: Money.schema() - }) -}) { - get total(): Money { - const itemsTotal = this.props.items.reduce( - (sum, item) => sum.add(item.price.multiply(item.quantity)), - new Money({ amount: 0, currency: this.props.items[0]?.price.props.currency || 'USD' }) - ) +### Instance members - return itemsTotal.add(this.props.tax) - } -} -``` +| Member | Description | +| ------------- | ----------------------------------------------------------------- | +| `props` | The validated, readonly data | +| `toJSON()` | JSON-compatible representation (respects custom `toJSON` option) | -## TypeScript Support +### Static members -This library is built with TypeScript-first design principles: +| 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) | -- **Full type inference** from Zod schemas -- **Compile-time type safety** for all operations -- **IntelliSense support** for properties and methods -- **Type narrowing** for union members -- **Generic type utilities** for extracting types from value objects +### Type helpers -```typescript -// TypeScript understands the exact shape of your value objects -const user = Customer.fromJSON(data) -user.props.email.props // TypeScript knows this is a string -user.props.addresses?.[0]?.props.zipCode // Optional chaining with proper types - -// Union type narrowing works automatically -const pet = Pet.fromJSON(data) -if (pet instanceof Dog) { // or pet.props.type === 'dog' - pet.props.woofs // TypeScript knows this is boolean and available - // pet.props.purrs // ❌ TypeScript error: Property doesn't exist on Dog -} -``` +| Helper | Resolves to | +| ------------------------------- | ------------------------------------------------------ | +| `ValueObject.inferProps` | The validated `props` shape | +| `ValueObject.inferJSON` | The return type of `toJSON()` | +| `ValueObject.inferInput` | The accepted input: schema input **or** an instance | ## License -MIT +MIT — see [LICENSE](LICENSE). ## Changelog -See [CHANGELOG.md](CHANGELOG.md) for release notes and version history. +See [CHANGELOG.md](CHANGELOG.md) for release notes. diff --git a/src/bundle.ts b/src/bundle.ts index 3f78429..e8a91ce 100644 --- a/src/bundle.ts +++ b/src/bundle.ts @@ -1,2 +1,3 @@ export * from './value-object' +export { extend as extends } from './value-object' export * from './union' diff --git a/src/union.ts b/src/union.ts index 2f79c5f..97516eb 100644 --- a/src/union.ts +++ b/src/union.ts @@ -1,5 +1,5 @@ import z from 'zod' -import {ValueObjectConstructor, ValueObjectInstance} from './value-object' +import { ValueObjectConstructor, ValueObjectInstance } from './value-object' import { extractSchema, extractZodLiteralValueFromObjectSchema, @@ -42,22 +42,22 @@ type DiscriminatorOf = C extends ValueObjectConstructor< infer Z, any > - ? z.output extends {[P in D]: infer V} - ? V - : never + ? z.output extends { [P in D]: infer V } + ? V + : never : never type ValidatedUnionMembers< D extends string, T extends Record>, > = { - [K in keyof T]: K extends DiscriminatorOf + [K in keyof T]: K extends DiscriminatorOf ? unknown : { - DISCRIMINATOR_MISMATCH: `Schema discriminator literal does not match key "${K & - string}"` - } - } + DISCRIMINATOR_MISMATCH: `Schema discriminator literal does not match key "${K & + string}"` + } +} export interface ValueObjectUnion< T extends Record>, @@ -130,11 +130,11 @@ export function defineUnion< types.length === 1 ? z.literal(types[0]) : z.union( - types.map((type) => z.literal(type)) as [ - z.ZodLiteral, - ...z.ZodLiteral[], - ], - ) + types.map((type) => z.literal(type)) as [ + z.ZodLiteral, + ...z.ZodLiteral[], + ], + ) return z .object({ diff --git a/src/value-object.ts b/src/value-object.ts index 00d31d0..3039786 100644 --- a/src/value-object.ts +++ b/src/value-object.ts @@ -107,7 +107,7 @@ export interface ValueObjectConstructor< props: z.input | InstanceType, ): InstanceType - new(props: z.output): ValueObjectInstance + new (props: z.output): ValueObjectInstance } /** @@ -142,7 +142,7 @@ export function define< schema: () => T toJSON?: (value: z.output) => JS }): ValueObjectConstructor { - const {id} = options + const { id } = options const getSchema = once(options.schema) const schema = once(function (klass: ValueObjectConstructor) { @@ -195,3 +195,153 @@ export function define< return DefinedValueObject as unknown as ValueObjectConstructor } + +/** + * Extracts the Zod schema type from a value object constructor. + * Local copy of the helper in `./union` to avoid a cross-file import. + */ +type SchemaOf

= P extends ValueObjectConstructor + ? Z + : never + +/** + * The user-defined methods/getters on a parent value object class — i.e. + * everything other than the structural members of `ValueObjectInstance`. + * Stripping by `keyof ValueObjectInstance<...>` removes `props`, `toJSON`, + * `__schema`, and `[ValueObjectIdSymbol]` in one go so they can be + * re-supplied with the extended types. + */ +type ParentExtras

= Omit< + InstanceType

any)>, + keyof ValueObjectInstance +> + +type ExtendedInstance< + P extends ValueObjectConstructor, + ID extends string, + NewT extends z.ZodTypeAny, + NewJS, +> = ParentExtras

& ValueObjectInstance + +/** + * A mapped type over `keyof T` which strips construct/call signatures — + * leaving only the named static members. We use it so we can re-attach a + * single, more specific `new()` signature without TypeScript treating it as + * an overload of the parent's constructor (which would otherwise trigger + * "Base constructors must all have the same return type" when the result is + * used in a `class X extends ...` clause). + */ +type StaticsOf = { [K in keyof T]: T[K] } + +export type ExtendedValueObjectConstructor< + P extends ValueObjectConstructor, + ID extends string, + NewT extends z.ZodTypeAny, + NewJS, +> = StaticsOf> & { + new (props: z.output): ExtendedInstance +} + +/** + * Sentinel returned (in place of the extended constructor) when the schema + * transform produces a type that is *not* assignable to the parent's output. + * It is intentionally not constructable, so `class X extends extend(...) {}` + * fails to type-check on the offending call. + */ +export type SchemaTransformOutputMismatchError = { + __valueObjectError: 'Schema transform output must be assignable to the parent schema output' +} + +/** + * Derives a new value object class from an existing one. The returned class + * extends `parent` directly, so `instanceof` and inherited methods work, and + * the new schema is layered on top of the parent's via `options.schema`. + * + * @example + * class Email extends ValueObject.define({ + * id: 'Email', + * schema: () => z.string().email(), + * }) { + * get domain() { return this.props.split('@')[1] } + * } + * + * class GoogleEmail extends ValueObject.extends(Email, { + * id: 'GoogleEmail', + * schema: (prev) => prev.refine((s) => s.endsWith('@google.com'), 'must be a google email'), + * }) { + * get isWorkspace() { return this.props.endsWith('@workspace.google.com') } + * } + * + * const ge = GoogleEmail.fromJSON('alice@google.com') + * ge instanceof Email // true — prototype chain preserved + * ge.domain // 'google.com' — inherited from Email + * ge.isWorkspace // false — defined on GoogleEmail + */ +export function extend< + P extends ValueObjectConstructor, + ID extends string, + NewT extends z.ZodTypeAny, + NewJS = z.output, +>( + parent: P, + options: { + id: ID + schema: (prev: SchemaOf

) => NewT + toJSON?: (value: z.output) => NewJS + }, +): z.output extends z.output> + ? ExtendedValueObjectConstructor + : SchemaTransformOutputMismatchError { + const { id } = options + + const getSchema = once(() => + options.schema((parent as any)[RAW_SCHEMA_ACCESSOR_KEY]), + ) + + const schemaFn = once(function (klass: any) { + return instanceOrConstruct(klass, getSchema()) + }) + + const schemaPrimitiveFn = once(function (klass: any) { + return getSchema().transform((value: any) => new klass(value)) + }) + + const Extended = class extends (parent as any) { + static [ValueObjectIdSymbol] = id + static get [RAW_SCHEMA_ACCESSOR_KEY]() { + return getSchema() + } + [ValueObjectIdSymbol] = id + + static schema(this: any) { + return schemaFn(this) + } + + static schemaPrimitive(this: any) { + return schemaPrimitiveFn(this) + } + + static schemaRaw() { + return getSchema() + } + + static fromJSON(this: any, props: any) { + return this.schema().parse(props) + } + } + + if (options.toJSON) { + const customToJSON = options.toJSON + Object.defineProperty(Extended.prototype, 'toJSON', { + value: function toJSON(this: any) { + return recursivelyToJSON(customToJSON(this.props)) + }, + writable: true, + configurable: true, + }) + } + + return Extended as unknown as z.output extends z.output> + ? ExtendedValueObjectConstructor + : SchemaTransformOutputMismatchError +} diff --git a/test/extends.test.ts b/test/extends.test.ts new file mode 100644 index 0000000..ed75ca6 --- /dev/null +++ b/test/extends.test.ts @@ -0,0 +1,457 @@ +import {describe, it, expect, expectTypeOf} from 'vitest' +import z from 'zod' +import {ValueObject} from '../src' +import {extractSchema} from '../src/utils' + +describe('ValueObject.extends', () => { + describe('Refining a primitive value object', () => { + class Email extends ValueObject.define({ + id: 'Email', + schema: () => z.string().email(), + }) { + get domain() { + return this.props.split('@')[1] + } + } + + class GoogleEmail extends ValueObject.extends(Email, { + id: 'GoogleEmail', + schema: (prev) => + prev.refine( + (s) => /@(?:[\w-]+\.)*google\.com$/.test(s), + 'must be a google email', + ), + }) { + get isWorkspace() { + return this.props.endsWith('@workspace.google.com') + } + } + + it('parses a valid value through the refined schema', () => { + const ge = GoogleEmail.fromJSON('alice@google.com') + expect(ge.props).toBe('alice@google.com') + }) + + it('rejects values that fail the new refinement', () => { + expect(() => GoogleEmail.fromJSON('alice@yahoo.com')).toThrow( + /must be a google email/, + ) + }) + + it('still rejects values that fail the parent schema', () => { + expect(() => GoogleEmail.fromJSON('not-an-email')).toThrow() + }) + + it('preserves the prototype chain — instanceof parent and self', () => { + const ge = GoogleEmail.fromJSON('alice@google.com') + expect(ge).toBeInstanceOf(GoogleEmail) + expect(ge).toBeInstanceOf(Email) + }) + + it('inherits getters defined on the parent class', () => { + const ge = GoogleEmail.fromJSON('alice@google.com') + expect(ge.domain).toBe('google.com') + }) + + it('exposes getters defined on the extended user class', () => { + const ge = GoogleEmail.fromJSON('alice@workspace.google.com') + expect(ge.isWorkspace).toBe(true) + const ge2 = GoogleEmail.fromJSON('alice@google.com') + expect(ge2.isWorkspace).toBe(false) + }) + + it('does not affect the parent — Email still accepts non-google emails', () => { + const e = Email.fromJSON('alice@yahoo.com') + expect(e.props).toBe('alice@yahoo.com') + expect(e).not.toBeInstanceOf(GoogleEmail) + }) + + it('Email.schema() and GoogleEmail.schema() are independent', () => { + const emailResult = Email.schema().safeParse('alice@yahoo.com') + const googleResult = GoogleEmail.schema().safeParse('alice@yahoo.com') + expect(emailResult.success).toBe(true) + expect(googleResult.success).toBe(false) + }) + + it('round-trips through schemaPrimitive without producing a parent instance', () => { + const result = GoogleEmail.schemaPrimitive().parse('alice@google.com') + expect(result).toBeInstanceOf(GoogleEmail) + expect(result).toBeInstanceOf(Email) + }) + }) + + describe('Extending an object-shaped value object via .and', () => { + class Person extends ValueObject.define({ + id: 'Person', + schema: () => z.object({name: z.string()}), + }) { + greet() { + return `hi ${this.props.name}` + } + } + + class Employee extends ValueObject.extends(Person, { + id: 'Employee', + schema: (prev) => prev.and(z.object({company: z.string()})), + }) { + get summary() { + return `${this.props.name} @ ${this.props.company}` + } + } + + it('parses both parent and new fields', () => { + const e = Employee.fromJSON({name: 'Alice', company: 'Acme'}) + expect(e.props).toEqual({name: 'Alice', company: 'Acme'}) + }) + + it('rejects input missing the new field', () => { + expect(() => Employee.fromJSON({name: 'Alice'} as any)).toThrow() + }) + + it('inherits parent methods that read parent fields', () => { + const e = Employee.fromJSON({name: 'Alice', company: 'Acme'}) + expect(e.greet()).toBe('hi Alice') + }) + + it('exposes new methods that read both parent and new fields', () => { + const e = Employee.fromJSON({name: 'Alice', company: 'Acme'}) + expect(e.summary).toBe('Alice @ Acme') + }) + + it('is instanceof both Employee and Person', () => { + const e = Employee.fromJSON({name: 'Alice', company: 'Acme'}) + expect(e).toBeInstanceOf(Employee) + expect(e).toBeInstanceOf(Person) + }) + }) + + describe('toJSON behaviour', () => { + class Money extends ValueObject.define({ + id: 'Money', + schema: () => z.object({amount: z.number(), currency: z.string()}), + toJSON: (v) => `${v.amount} ${v.currency}`, + }) {} + + it('inherits the parent toJSON when no override is provided', () => { + class TaxedMoney extends ValueObject.extends(Money, { + id: 'TaxedMoney', + schema: (prev) => prev.and(z.object({taxRate: z.number()})), + }) {} + + const tm = TaxedMoney.fromJSON({amount: 100, currency: 'USD', taxRate: 0.1}) + expect(tm.toJSON()).toBe('100 USD') + expect(JSON.stringify(tm)).toBe('"100 USD"') + }) + + it('uses a custom toJSON when provided in extend options', () => { + class TaxedMoney extends ValueObject.extends(Money, { + id: 'TaxedMoney', + schema: (prev) => prev.and(z.object({taxRate: z.number()})), + toJSON: (v) => `${v.amount} ${v.currency} (+${v.taxRate * 100}%)`, + }) {} + + const tm = TaxedMoney.fromJSON({amount: 100, currency: 'USD', taxRate: 0.1}) + expect(tm.toJSON()).toBe('100 USD (+10%)') + expect(JSON.stringify(tm)).toBe('"100 USD (+10%)"') + }) + }) + + describe('Interop with existing public API', () => { + class Email extends ValueObject.define({ + id: 'Email', + schema: () => z.string().email(), + }) {} + + class GoogleEmail extends ValueObject.extends(Email, { + id: 'GoogleEmail', + schema: (prev) => prev.refine((s) => s.endsWith('@google.com')), + }) {} + + it('extractSchema returns the extended schema, not the parent schema', () => { + const parentSchema = extractSchema(Email) + const extendedSchema = extractSchema(GoogleEmail) + expect(parentSchema).not.toBe(extendedSchema) + + // The extended schema rejects non-google emails; the parent does not. + expect(parentSchema.safeParse('a@yahoo.com').success).toBe(true) + expect(extendedSchema.safeParse('a@yahoo.com').success).toBe(false) + }) + + it('inferProps / inferJSON / inferInput resolve to the extended types', () => { + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf< + ValueObject.inferInput + >().toEqualTypeOf() + }) + + it('inferProps with a custom JSON serializer on the extension', () => { + class Money extends ValueObject.define({ + id: 'Money', + schema: () => z.object({amount: z.number(), currency: z.string()}), + }) {} + + class TaxedMoney extends ValueObject.extends(Money, { + id: 'TaxedMoney', + schema: (prev) => prev.and(z.object({taxRate: z.number()})), + toJSON: (v) => `${v.amount} ${v.currency}`, + }) {} + + expectTypeOf>().toEqualTypeOf<{ + amount: number + currency: string + } & {taxRate: number}>() + expectTypeOf>().toEqualTypeOf() + }) + + it('GoogleEmail.fromJSON accepts string and returns GoogleEmail', () => { + const result = GoogleEmail.fromJSON('alice@google.com') + expectTypeOf(result).toEqualTypeOf() + // Also accepts an existing instance (idempotent fromJSON): + const result2 = GoogleEmail.fromJSON(result) + expectTypeOf(result2).toEqualTypeOf() + }) + + it('works as a member of a defineUnion (literal still inherited from parent)', () => { + class Dog extends ValueObject.define({ + id: 'Dog', + schema: () => + z.object({type: z.literal('dog'), age: z.number(), name: z.string()}), + }) {} + + class Cat extends ValueObject.define({ + id: 'Cat', + schema: () => + z.object({type: z.literal('cat'), name: z.string()}), + }) {} + + // Puppy inherits the 'dog' literal from Dog and refines age < 1. + class Puppy extends ValueObject.extends(Dog, { + id: 'Puppy', + schema: (prev) => prev.refine((d) => d.age < 1, 'must be under 1 year'), + }) {} + + const Pets = ValueObject.defineUnion('type', () => ({ + dog: Puppy, + cat: Cat, + })) + + // Parses through the *extended* validation: an old dog is rejected. + const puppy = Pets.fromJSON({type: 'dog', age: 0.5, name: 'Rex'}) + expect(puppy).toBeInstanceOf(Puppy) + expect(puppy).toBeInstanceOf(Dog) + + expect(() => + Pets.fromJSON({type: 'dog', age: 5, name: 'Rex'}), + ).toThrow(/must be under 1 year/) + + // The discriminator validator on defineUnion uses the inherited 'dog' + // literal — keying Puppy under any other label is a type error. + ValueObject.defineUnion('type', () => ({ + // @ts-expect-error - Puppy's discriminator literal is "dog", not "puppy" + puppy: Puppy, + cat: Cat, + })) + }) + + it('ValueObjectSchema resolves to the extended schema', () => { + type S = ValueObject.ValueObjectSchema + expectTypeOf>().toEqualTypeOf() + }) + }) + + describe('Type-level constraint on schema transform output', () => { + class Email extends ValueObject.define({ + id: 'Email', + schema: () => z.string().email(), + }) {} + + it('rejects transforms that change the output type (string -> number)', () => { + const broken = ValueObject.extends(Email, { + id: 'EmailLength', + schema: (prev) => prev.transform((s) => s.length), + }) + + // The function returns a non-constructable sentinel error type. + // Trying to use it as a base class is therefore a type error. + expectTypeOf(broken).toEqualTypeOf< + ValueObject.SchemaTransformOutputMismatchError + >() + + // @ts-expect-error - cannot extend a non-constructable sentinel error type + class Broken extends broken {} + void Broken + }) + + it('accepts transforms that preserve the output type (refine)', () => { + const ok = ValueObject.extends(Email, { + id: 'GoogleEmail', + schema: (prev) => prev.refine((s) => s.endsWith('@google.com')), + }) + + // Should NOT be the sentinel; should be a real constructor. + expectTypeOf(ok).not.toEqualTypeOf< + ValueObject.SchemaTransformOutputMismatchError + >() + + // Extending it as a class works. + class GoogleEmail extends ok {} + const ge = GoogleEmail.fromJSON('alice@google.com') + expect(ge).toBeInstanceOf(GoogleEmail) + }) + + it('accepts transforms that broaden via .and (object intersection)', () => { + class Person extends ValueObject.define({ + id: 'Person', + schema: () => z.object({name: z.string()}), + }) {} + + const ok = ValueObject.extends(Person, { + id: 'Employee', + schema: (prev) => prev.and(z.object({company: z.string()})), + }) + + expectTypeOf(ok).not.toEqualTypeOf< + ValueObject.SchemaTransformOutputMismatchError + >() + }) + }) + + describe('Sibling extensions (Animal -> Dog, Animal -> Cat)', () => { + 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!` + } + } + + it('Dog parses parent + new fields and exposes both methods', () => { + const dog = Dog.fromJSON({name: 'Rex', age: 3, breed: 'Labrador'}) + expect(dog.props).toEqual({name: 'Rex', age: 3, breed: 'Labrador'}) + expect(dog.description).toBe('Rex, age 3') + expect(dog.bark()).toBe('Rex says woof!') + }) + + it('Cat parses parent + new fields and exposes both methods', () => { + const cat = Cat.fromJSON({name: 'Whiskers', age: 5, indoor: true}) + expect(cat.props).toEqual({name: 'Whiskers', age: 5, indoor: true}) + expect(cat.description).toBe('Whiskers, age 5') + expect(cat.meow()).toBe('Whiskers says meow!') + }) + + it('Dog and Cat are both instanceof Animal but not each other', () => { + const dog = Dog.fromJSON({name: 'Rex', age: 3, breed: 'Labrador'}) + const cat = Cat.fromJSON({name: 'Whiskers', age: 5, indoor: true}) + + expect(dog).toBeInstanceOf(Dog) + expect(dog).toBeInstanceOf(Animal) + expect(dog).not.toBeInstanceOf(Cat) + + expect(cat).toBeInstanceOf(Cat) + expect(cat).toBeInstanceOf(Animal) + expect(cat).not.toBeInstanceOf(Dog) + }) + + it('Dog rejects input missing the new field', () => { + expect(() => Dog.fromJSON({name: 'Rex', age: 3} as any)).toThrow() + }) + + it('Cat rejects input missing the new field', () => { + expect(() => Cat.fromJSON({name: 'Whiskers', age: 5} as any)).toThrow() + }) + + it('Dog and Cat schemas do not bleed into each other', () => { + // A Dog payload is not a valid Cat (missing `indoor`, has stray `breed`). + expect(() => + Cat.fromJSON({name: 'Rex', age: 3, breed: 'Labrador'} as any), + ).toThrow() + // And vice versa. + expect(() => + Dog.fromJSON({name: 'Whiskers', age: 5, indoor: true} as any), + ).toThrow() + }) + + it('Animal still parses bare parent payloads (siblings do not affect parent)', () => { + const a = Animal.fromJSON({name: 'Generic', age: 7}) + expect(a.props).toEqual({name: 'Generic', age: 7}) + expect(a).toBeInstanceOf(Animal) + expect(a).not.toBeInstanceOf(Dog) + expect(a).not.toBeInstanceOf(Cat) + }) + + it('JSON.stringify round-trips a Dog through fromJSON', () => { + const dog = Dog.fromJSON({name: 'Rex', age: 3, breed: 'Labrador'}) + const json = JSON.stringify(dog) + const restored = Dog.fromJSON(JSON.parse(json)) + expect(restored).toBeInstanceOf(Dog) + expect(restored).toBeInstanceOf(Animal) + expect(restored.props).toEqual(dog.props) + expect(restored.bark()).toBe(dog.bark()) + }) + }) + + describe('Multi-level extension', () => { + class Email extends ValueObject.define({ + id: 'Email', + schema: () => z.string().email(), + }) { + get domain() { + return this.props.split('@')[1] + } + } + + class GoogleEmail extends ValueObject.extends(Email, { + id: 'GoogleEmail', + schema: (prev) => + prev.refine((s) => /@(?:[\w-]+\.)*google\.com$/.test(s)), + }) {} + + class GoogleWorkspaceEmail extends ValueObject.extends(GoogleEmail, { + id: 'GoogleWorkspaceEmail', + schema: (prev) => prev.refine((s) => s.endsWith('@workspace.google.com')), + }) {} + + it('keeps the full instanceof chain through two levels of extension', () => { + const w = GoogleWorkspaceEmail.fromJSON('alice@workspace.google.com') + expect(w).toBeInstanceOf(GoogleWorkspaceEmail) + expect(w).toBeInstanceOf(GoogleEmail) + expect(w).toBeInstanceOf(Email) + expect(w.domain).toBe('workspace.google.com') + }) + + it('each level enforces its own validation', () => { + // Valid at all three levels. + expect(() => + GoogleWorkspaceEmail.fromJSON('alice@workspace.google.com'), + ).not.toThrow() + // Valid at Email + GoogleEmail, fails at GoogleWorkspaceEmail. + expect(() => GoogleWorkspaceEmail.fromJSON('alice@google.com')).toThrow() + // Valid only at Email. + expect(() => GoogleWorkspaceEmail.fromJSON('alice@yahoo.com')).toThrow() + }) + }) +}) From cdda022451ece2f98fccf0125df287626a1ffb55 Mon Sep 17 00:00:00 2001 From: James Apple Date: Wed, 8 Apr 2026 13:19:34 +1000 Subject: [PATCH 3/7] Equality checking --- README.md | 54 ++++++- src/utils.ts | 52 +++++++ src/value-object.ts | 29 ++++ test/equals.test.ts | 347 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 478 insertions(+), 4 deletions(-) create mode 100644 test/equals.test.ts diff --git a/README.md b/README.md index 0eeb93f..03fb305 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ JSON.stringify({ email }) // '{"email":"alice@example.com"}' - [Defining a value object](#defining-a-value-object) - [Custom JSON serialization](#custom-json-serialization) - [Composing value objects](#composing-value-objects) + - [Structural equality](#structural-equality) - [Extending a value object](#extending-a-value-object) - [Discriminated unions](#discriminated-unions) - [Schema Methods](#schema-methods) @@ -199,6 +200,50 @@ 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. + +```typescript +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. + +```typescript +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 +``` + ### 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. @@ -405,10 +450,11 @@ Returns an object with `fromJSON()`, `schema()`, and `isInstance()` methods. ### Instance members -| Member | Description | -| ------------- | ----------------------------------------------------------------- | -| `props` | The validated, readonly data | -| `toJSON()` | JSON-compatible representation (respects custom `toJSON` option) | +| 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 | ### Static members diff --git a/src/utils.ts b/src/utils.ts index b5ec0db..36c7dca 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -122,6 +122,58 @@ export type ToJSONOutput = T extends PrimitiveType } : never +/** + * Deeply compares two arbitrary values, with semantics that match the + * `equals()` method on `ValueObject` instances: + * + * - Identical references (and `Object.is`-equal primitives) compare equal. + * - When either side is a value object instance (carries `ValueObjectIdSymbol`) + * the comparison is delegated to that instance's `equals()` method, so + * user-defined overrides are honoured. + * - `Date` instances compare by their numeric time. + * - Arrays must have the same length and equal elements *in order*. + * - Plain objects are compared by their own enumerable keys, in any order. + * - Anything else (Map, Set, RegExp, class instances, functions) falls back + * to reference equality. If you need richer semantics, override `equals` + * on the value object that owns the field. + */ +export function deepEquals(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true + if (a === null || b === null) return false + if (typeof a !== 'object' || typeof b !== 'object') return false + + const aIsVO = ValueObjectIdSymbol in (a as object) + const bIsVO = ValueObjectIdSymbol in (b as object) + if (aIsVO !== bIsVO) return false + if (aIsVO) { + // Delegate fully to the VO's own `equals` method so user overrides win. + // The default implementation handles the ID check internally. + return (a as any).equals(b) + } + + if (a instanceof Date || b instanceof Date) { + return a instanceof Date && b instanceof Date && a.getTime() === b.getTime() + } + + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b)) return false + if (a.length !== b.length) return false + for (let i = 0; i < a.length; i++) { + if (!deepEquals(a[i], b[i])) return false + } + return true + } + + const aKeys = Object.keys(a as object) + const bKeys = Object.keys(b as object) + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, key)) return false + if (!deepEquals((a as any)[key], (b as any)[key])) return false + } + return true +} + export function recursivelyToJSON(value: T): ToJSONOutput { if (value === null || value === undefined) { return value as any diff --git a/src/value-object.ts b/src/value-object.ts index 3039786..88eef8b 100644 --- a/src/value-object.ts +++ b/src/value-object.ts @@ -2,6 +2,7 @@ import z from 'zod' import { RAW_SCHEMA_ACCESSOR_KEY, ToJSONOutput, + deepEquals, instanceOrConstruct, once, recursivelyToJSON, @@ -78,6 +79,21 @@ export interface ValueObjectInstance< readonly __schema: T toJSON(): ToJSONOutput + + /** + * Structural equality. Returns `true` only when `other` is a value object + * of the same type (matching `id`) whose `props` are deeply equal: + * + * - 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()` method, so + * user overrides are honoured all the way down. + * - `Date` instances compare by timestamp. + * + * Subclasses may override this with `override equals(other: Self): boolean` + * to express domain-specific identity (e.g. comparing only an `id` field). + */ + equals(other: this): boolean } export interface ValueObjectConstructor< @@ -191,6 +207,19 @@ export function define< } return recursivelyToJSON(this.props) as ToJSONOutput } + + equals(other: unknown): boolean { + if ((this as any) === other) return true + if (other === null || typeof other !== 'object') return false + if (!(ValueObjectIdSymbol in other)) return false + if ( + (other as any)[ValueObjectIdSymbol] !== + (this as any)[ValueObjectIdSymbol] + ) { + return false + } + return deepEquals(this.props, (other as any).props) + } } return DefinedValueObject as unknown as ValueObjectConstructor diff --git a/test/equals.test.ts b/test/equals.test.ts new file mode 100644 index 0000000..12f7352 --- /dev/null +++ b/test/equals.test.ts @@ -0,0 +1,347 @@ +import {describe, it, expect, expectTypeOf} from 'vitest' +import z from 'zod' +import {ValueObject} from '../src' + +describe('ValueObject#equals', () => { + describe('Primitive-backed value objects', () => { + class Email extends ValueObject.define({ + id: 'Email', + schema: () => z.string().email(), + }) {} + + it('returns true for two instances built from the same string', () => { + const a = Email.fromJSON('alice@example.com') + const b = Email.fromJSON('alice@example.com') + expect(a).not.toBe(b) // different references + expect(a.equals(b)).toBe(true) + expect(b.equals(a)).toBe(true) + }) + + it('returns false for instances built from different strings', () => { + const a = Email.fromJSON('alice@example.com') + const b = Email.fromJSON('bob@example.com') + expect(a.equals(b)).toBe(false) + }) + + it('returns true when compared to itself', () => { + const a = Email.fromJSON('alice@example.com') + expect(a.equals(a)).toBe(true) + }) + }) + + describe('Object-backed value objects', () => { + class Address extends ValueObject.define({ + id: 'Address', + schema: () => + z.object({ + street: z.string(), + city: z.string(), + zipCode: z.string(), + }), + }) {} + + it('returns true when keys are constructed in the same order', () => { + const a = Address.fromJSON({ + street: '123 Main St', + city: 'Springfield', + zipCode: '12345', + }) + const b = Address.fromJSON({ + street: '123 Main St', + city: 'Springfield', + zipCode: '12345', + }) + expect(a.equals(b)).toBe(true) + }) + + it('returns true regardless of the original key order', () => { + const a = Address.fromJSON({ + street: '123 Main St', + city: 'Springfield', + zipCode: '12345', + }) + const b = Address.fromJSON({ + // Reverse order — should still compare equal. + zipCode: '12345', + city: 'Springfield', + street: '123 Main St', + }) + expect(a.equals(b)).toBe(true) + }) + + it('returns false when any field differs', () => { + const a = Address.fromJSON({ + street: '123 Main St', + city: 'Springfield', + zipCode: '12345', + }) + const b = Address.fromJSON({ + street: '123 Main St', + city: 'Springfield', + zipCode: '54321', + }) + expect(a.equals(b)).toBe(false) + }) + }) + + describe('Nested value objects', () => { + class Email extends ValueObject.define({ + id: 'Email', + schema: () => z.string().email(), + }) {} + + class User extends ValueObject.define({ + id: 'User', + schema: () => + z.object({ + name: z.string(), + email: Email.schema(), + }), + }) {} + + it('uses the inner value object equals() for nested fields', () => { + const a = User.fromJSON({name: 'Alice', email: 'alice@example.com'}) + const b = User.fromJSON({name: 'Alice', email: 'alice@example.com'}) + expect(a.props.email).not.toBe(b.props.email) // different references + expect(a.props.email.equals(b.props.email)).toBe(true) + expect(a.equals(b)).toBe(true) + }) + + it('returns false when a nested value object differs', () => { + const a = User.fromJSON({name: 'Alice', email: 'alice@example.com'}) + const b = User.fromJSON({name: 'Alice', email: 'alice@other.com'}) + expect(a.equals(b)).toBe(false) + }) + + it('honours a user override on the nested value object', () => { + // EmailCI compares case-insensitively. + class EmailCI extends ValueObject.define({ + id: 'EmailCI', + schema: () => z.string().email(), + }) { + override equals(other: EmailCI): boolean { + if (!(other instanceof EmailCI)) return false + return this.props.toLowerCase() === other.props.toLowerCase() + } + } + + class Account extends ValueObject.define({ + id: 'Account', + schema: () => + z.object({ + name: z.string(), + email: EmailCI.schema(), + }), + }) {} + + const a = Account.fromJSON({name: 'Alice', email: 'Alice@Example.com'}) + const b = Account.fromJSON({name: 'Alice', email: 'alice@example.com'}) + + // Direct comparison goes through the override. + expect(a.props.email.equals(b.props.email)).toBe(true) + + // And so does the parent comparison, because deepEquals dispatches to + // the inner equals() rather than comparing the strings itself. + expect(a.equals(b)).toBe(true) + }) + }) + + describe('Arrays in props', () => { + class Tag extends ValueObject.define({ + id: 'Tag', + schema: () => z.string(), + }) {} + + class Post extends ValueObject.define({ + id: 'Post', + schema: () => + z.object({ + title: z.string(), + tags: z.array(Tag.schema()), + }), + }) {} + + it('returns true for arrays with identical contents in the same order', () => { + const a = Post.fromJSON({title: 'Hello', tags: ['ts', 'zod', 'ddd']}) + const b = Post.fromJSON({title: 'Hello', tags: ['ts', 'zod', 'ddd']}) + expect(a.equals(b)).toBe(true) + }) + + it('returns false when arrays contain the same elements in a different order', () => { + const a = Post.fromJSON({title: 'Hello', tags: ['ts', 'zod', 'ddd']}) + const b = Post.fromJSON({title: 'Hello', tags: ['zod', 'ts', 'ddd']}) + expect(a.equals(b)).toBe(false) + }) + + it('returns false when arrays differ in length', () => { + const a = Post.fromJSON({title: 'Hello', tags: ['ts', 'zod']}) + const b = Post.fromJSON({title: 'Hello', tags: ['ts', 'zod', 'ddd']}) + expect(a.equals(b)).toBe(false) + }) + + it('compares nested value objects inside arrays via their own equals()', () => { + const a = Post.fromJSON({title: 'Hello', tags: ['ts', 'zod']}) + const b = Post.fromJSON({title: 'Hello', tags: ['ts', 'zod']}) + // Different Tag instances at index 0 but should still be equal. + expect(a.props.tags[0]).not.toBe(b.props.tags[0]) + expect(a.equals(b)).toBe(true) + }) + }) + + describe('Cross-type comparisons', () => { + class Email extends ValueObject.define({ + id: 'Email', + schema: () => z.string().email(), + }) {} + + class Username extends ValueObject.define({ + id: 'Username', + schema: () => z.string().min(1), + }) {} + + it('returns false when comparing two different VO types with the same primitive props', () => { + const e = Email.fromJSON('alice@example.com') + // Cast through any: this is the runtime check, not the type check. + const u = Username.fromJSON('alice@example.com') as any + expect(e.equals(u)).toBe(false) + }) + + it('returns false when compared with null, undefined, or a plain object', () => { + const e = Email.fromJSON('alice@example.com') + expect((e.equals as any)(null)).toBe(false) + expect((e.equals as any)(undefined)).toBe(false) + expect((e.equals as any)({props: 'alice@example.com'})).toBe(false) + expect((e.equals as any)('alice@example.com')).toBe(false) + }) + }) + + describe('Subclasses produced via ValueObject.extends', () => { + class Animal extends ValueObject.define({ + id: 'Animal', + schema: () => + z.object({name: z.string(), age: z.number().int().nonnegative()}), + }) {} + + class Dog extends ValueObject.extends(Animal, { + id: 'Dog', + schema: (prev) => prev.and(z.object({breed: z.string()})), + }) {} + + class Cat extends ValueObject.extends(Animal, { + id: 'Cat', + schema: (prev) => prev.and(z.object({indoor: z.boolean()})), + }) {} + + it('two Dog instances with identical props are equal', () => { + const a = Dog.fromJSON({name: 'Rex', age: 3, breed: 'Labrador'}) + const b = Dog.fromJSON({name: 'Rex', age: 3, breed: 'Labrador'}) + expect(a.equals(b)).toBe(true) + }) + + it('Dog and Cat are never equal even when shared fields match', () => { + const dog = Dog.fromJSON({name: 'Rex', age: 3, breed: 'Labrador'}) as any + const cat = Cat.fromJSON({name: 'Rex', age: 3, indoor: true}) as any + expect(dog.equals(cat)).toBe(false) + }) + + it('Dog and bare Animal with same shared fields are not equal (different IDs)', () => { + const dog = Dog.fromJSON({name: 'Rex', age: 3, breed: 'Labrador'}) as any + const animal = Animal.fromJSON({name: 'Rex', age: 3}) as any + expect(dog.equals(animal)).toBe(false) + expect(animal.equals(dog)).toBe(false) + }) + }) + + describe('User overrides', () => { + // Compare users by id only — common pattern for entity-like value objects. + 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 + } + } + + it('uses the override even when other fields differ', () => { + 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'}) + expect(a.equals(b)).toBe(true) + }) + + it('the override returns false when ids differ even if other fields match', () => { + const a = User.fromJSON({ + id: '123e4567-e89b-12d3-a456-426614174000', + name: 'Alice', + updatedAt: '2024-01-01', + }) + const b = User.fromJSON({ + id: '123e4567-e89b-12d3-a456-426614174001', + name: 'Alice', + updatedAt: '2024-01-01', + }) + expect(a.equals(b)).toBe(false) + }) + + it('the override is honoured when the User is nested inside another VO', () => { + class Comment extends ValueObject.define({ + id: 'Comment', + schema: () => + z.object({ + body: z.string(), + author: User.schema(), + }), + }) {} + + const id = '123e4567-e89b-12d3-a456-426614174000' + const a = Comment.fromJSON({ + body: 'hello', + author: {id, name: 'Alice', updatedAt: '2024-01-01'}, + }) + const b = Comment.fromJSON({ + body: 'hello', + // Same id, different name & timestamp — User override says equal. + author: {id, name: 'Alice Renamed', updatedAt: '2024-12-31'}, + }) + expect(a.equals(b)).toBe(true) + }) + + it('the override parameter type is the subclass', () => { + // Type-level smoke check: `override equals(other: User)` is valid. + expectTypeOf(User.prototype.equals) + .parameter(0) + .toEqualTypeOf() + }) + }) + + describe('Date and primitive edge cases in props', () => { + class Event extends ValueObject.define({ + id: 'Event', + schema: () => + z.object({ + name: z.string(), + when: z.date(), + }), + }) {} + + it('compares Date fields by their numeric time, not by reference', () => { + const a = Event.fromJSON({name: 'launch', when: new Date('2024-01-01T00:00:00Z')}) + const b = Event.fromJSON({name: 'launch', when: new Date('2024-01-01T00:00:00Z')}) + expect(a.props.when).not.toBe(b.props.when) + expect(a.equals(b)).toBe(true) + }) + + it('returns false when Date fields differ', () => { + const a = Event.fromJSON({name: 'launch', when: new Date('2024-01-01T00:00:00Z')}) + const b = Event.fromJSON({name: 'launch', when: new Date('2024-01-02T00:00:00Z')}) + expect(a.equals(b)).toBe(false) + }) + }) +}) From f5003d59b8620ad4ec7e5fbb2992646eca8be098 Mon Sep 17 00:00:00 2001 From: James Apple Date: Wed, 8 Apr 2026 17:57:39 +1000 Subject: [PATCH 4/7] Final update for v2 --- .changeset/chilly-vans-vanish.md | 5 ++ README.md | 20 ++++- src/union.ts | 18 +++++ src/utils.ts | 18 +---- src/value-object.ts | 110 +++++++++++++++++-------- test/ValueObject.test.ts | 133 ++++++++++++++++++++++++------- test/equals.test.ts | 3 +- 7 files changed, 226 insertions(+), 81 deletions(-) create mode 100644 .changeset/chilly-vans-vanish.md diff --git a/.changeset/chilly-vans-vanish.md b/.changeset/chilly-vans-vanish.md new file mode 100644 index 0000000..c311cfb --- /dev/null +++ b/.changeset/chilly-vans-vanish.md @@ -0,0 +1,5 @@ +--- +"@unruly-software/value-object": major +--- + +Upgrade and extend value object interface to be comparable to other major alternatives diff --git a/README.md b/README.md index 03fb305..3ec2dd8 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ JSON.stringify({ email }) // '{"email":"alice@example.com"}' - [Custom JSON serialization](#custom-json-serialization) - [Composing value objects](#composing-value-objects) - [Structural equality](#structural-equality) + - [Cloning](#cloning) - [Extending a value object](#extending-a-value-object) - [Discriminated unions](#discriminated-unions) - [Schema Methods](#schema-methods) @@ -244,6 +245,22 @@ 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()`. + +```typescript +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. @@ -401,12 +418,10 @@ This library sits in the small intersection of "schema validation" and "class-ba | [Valibot](https://valibot.dev) | Functional, tree-shakable | No | n/a — plain objects | Plain object out, no methods | | [io-ts](https://github.com/gcanti/io-ts) | Functional codecs (`fp-ts`) | No | n/a — combinators only | Plain object out, no methods | | [runtypes](https://github.com/runtypes/runtypes) | Functional combinators | No | n/a — `.withConstraint`, `.withBrand` | Plain object out, no methods | -| [type-fest `Tagged`](https://github.com/sindresorhus/type-fest) | Type-level brand only | No | n/a — types only | Trivial — value is the primitive | 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. -- **`type-fest`-style branding** is the lightest possible option but provides no runtime validation; you're responsible for parsing the value into the branded type yourself. - **`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. @@ -455,6 +470,7 @@ Returns an object with `fromJSON()`, `schema()`, and `isInstance()` methods. | `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 diff --git a/src/union.ts b/src/union.ts index 97516eb..3537a79 100644 --- a/src/union.ts +++ b/src/union.ts @@ -62,13 +62,31 @@ type ValidatedUnionMembers< export interface ValueObjectUnion< T extends Record>, > { + /** + * Zod schema for the union; accepts any member's input or instance and returns the matching instance. + * + * @example + * z.object({ pet: Pets.schema() }).parse({ pet: { type: 'dog', woofs: true } }) + */ schema(): z.ZodCustom, UnionInput> + /** + * Type guard for a specific member of the union. + * + * @example + * if (Pets.isInstance('dog', pet)) pet.props.woofs + */ isInstance( discriminator: K, value: unknown, ): value is ValueObjectInst + /** + * Parses raw input into the matching member instance. + * + * @example + * const pet = Pets.fromJSON({ type: 'cat', sharpClaws: false }) // Cat + */ fromJSON(input: UnionInput): UnionOutput } diff --git a/src/utils.ts b/src/utils.ts index 36c7dca..f67efba 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -40,7 +40,6 @@ export function instanceOrConstruct(klass: any, schema: z.ZodType) { export function extractSchema( valueObject: any, ): SCHEMA { - /** This field is not exposed */ const ctor = valueObject as any if (!ctor[RAW_SCHEMA_ACCESSOR_KEY]) { throw new Error( @@ -123,19 +122,8 @@ export type ToJSONOutput = T extends PrimitiveType : never /** - * Deeply compares two arbitrary values, with semantics that match the - * `equals()` method on `ValueObject` instances: - * - * - Identical references (and `Object.is`-equal primitives) compare equal. - * - When either side is a value object instance (carries `ValueObjectIdSymbol`) - * the comparison is delegated to that instance's `equals()` method, so - * user-defined overrides are honoured. - * - `Date` instances compare by their numeric time. - * - Arrays must have the same length and equal elements *in order*. - * - Plain objects are compared by their own enumerable keys, in any order. - * - Anything else (Map, Set, RegExp, class instances, functions) falls back - * to reference equality. If you need richer semantics, override `equals` - * on the value object that owns the field. + * Deeply compares two values with the same semantics as `ValueObject#equals`. + * Handles primitives, plain objects, arrays, dates, and value objects (delegating to their `equals`). */ export function deepEquals(a: unknown, b: unknown): boolean { if (Object.is(a, b)) return true @@ -146,8 +134,6 @@ export function deepEquals(a: unknown, b: unknown): boolean { const bIsVO = ValueObjectIdSymbol in (b as object) if (aIsVO !== bIsVO) return false if (aIsVO) { - // Delegate fully to the VO's own `equals` method so user overrides win. - // The default implementation handles the ID check internally. return (a as any).equals(b) } diff --git a/src/value-object.ts b/src/value-object.ts index 88eef8b..b63bddf 100644 --- a/src/value-object.ts +++ b/src/value-object.ts @@ -57,6 +57,12 @@ export type inferInput = T extends ValueObjectConstructor< ? z.input | T : never +/** + * Infers the raw schema input — the same as `inferInput` but excluding the instance type. + * + * @example + * type EmailRaw = ValueObject.inferRawInput // string + */ export type inferRawInput = T extends ValueObjectConstructor< string, infer Z, @@ -78,22 +84,40 @@ export interface ValueObjectInstance< readonly __schema: T + /** + * JSON-compatible representation of the value object. Honours the optional + * `toJSON` serializer passed to `define()` / `extend()`. + * + * @example + * Email.fromJSON('alice@example.com').toJSON() // 'alice@example.com' + */ toJSON(): ToJSONOutput /** - * Structural equality. Returns `true` only when `other` is a value object - * of the same type (matching `id`) whose `props` are deeply equal: - * - * - 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()` method, so - * user overrides are honoured all the way down. - * - `Date` instances compare by timestamp. + * Structural equality — same type and deeply-equal `props`. Override on a + * subclass to express domain-specific identity (e.g. comparing only `id`). * - * Subclasses may override this with `override equals(other: Self): boolean` - * to express domain-specific identity (e.g. comparing only an `id` field). + * @example + * class User extends ValueObject.define({ + * id: 'User', + * schema: () => z.object({ id: z.string(), name: z.string() }), + * }) { + * override equals(other: User) { return this.props.id === other.props.id } + * } */ equals(other: this): boolean + + /** + * Returns a duplicate instance by re-parsing `props` through the schema + * (Zod handles deep cloning) and constructing a new instance of the same class. + * + * @example + * const a = Address.fromJSON({ street: '1 Main St', tags: ['home'] }) + * const b = a.clone() + * b.props.tags.push('mutated') + * a.props.tags // ['home'] — original is untouched + */ + clone(): this } export interface ValueObjectConstructor< @@ -103,6 +127,14 @@ export interface ValueObjectConstructor< > { [ValueObjectIdSymbol]: ID + /** + * Zod schema accepting either a raw input or an existing instance, returning + * an instance. Use this when composing the value object inside other Zod schemas. + * + * @example + * const Form = z.object({ email: Email.schema() }) + * Form.parse({ email: 'a@b.com' }).email instanceof Email // true + */ schema>( this: CTOR, ): z.ZodUnion< @@ -112,12 +144,32 @@ export interface ValueObjectConstructor< ] > + /** + * Zod schema accepting only the raw primitive input (not an instance), + * returning an instance. Useful when parsing JSON from the wire. + * + * @example + * Email.schemaPrimitive().parse('a@b.com') instanceof Email // true + */ schemaPrimitive>( this: CTOR, ): z.ZodPipe, T>> + /** + * The raw underlying Zod schema with no instance wrapping. + * + * @example + * Email.schemaRaw().parse('a@b.com') // 'a@b.com' (string, not Email) + */ schemaRaw>(this: CTOR): T + /** + * Parses a raw input (or accepts an existing instance) and returns a validated instance. + * + * @example + * const email = Email.fromJSON('a@b.com') + * Email.fromJSON(email) === email // true — instances pass through + */ fromJSON>( this: CTOR, props: z.input | InstanceType, @@ -220,26 +272,27 @@ export function define< } return deepEquals(this.props, (other as any).props) } + + clone(): ValueObjectInstance { + const Ctor = this.constructor as new ( + props: z.output, + ) => ValueObjectInstance + const cloned = ( + Ctor as unknown as ValueObjectConstructor + ).fromJSON(this.props as any) + return cloned + } } return DefinedValueObject as unknown as ValueObjectConstructor } -/** - * Extracts the Zod schema type from a value object constructor. - * Local copy of the helper in `./union` to avoid a cross-file import. - */ +/** Extracts the Zod schema type from a value object constructor. */ type SchemaOf

= P extends ValueObjectConstructor ? Z : never -/** - * The user-defined methods/getters on a parent value object class — i.e. - * everything other than the structural members of `ValueObjectInstance`. - * Stripping by `keyof ValueObjectInstance<...>` removes `props`, `toJSON`, - * `__schema`, and `[ValueObjectIdSymbol]` in one go so they can be - * re-supplied with the extended types. - */ +/** Methods/getters defined on the parent class, excluding structural members. */ type ParentExtras

= Omit< InstanceType

any)>, keyof ValueObjectInstance @@ -252,14 +305,7 @@ type ExtendedInstance< NewJS, > = ParentExtras

& ValueObjectInstance -/** - * A mapped type over `keyof T` which strips construct/call signatures — - * leaving only the named static members. We use it so we can re-attach a - * single, more specific `new()` signature without TypeScript treating it as - * an overload of the parent's constructor (which would otherwise trigger - * "Base constructors must all have the same return type" when the result is - * used in a `class X extends ...` clause). - */ +/** Strips construct/call signatures from a type, leaving only named static members. */ type StaticsOf = { [K in keyof T]: T[K] } export type ExtendedValueObjectConstructor< @@ -272,10 +318,8 @@ export type ExtendedValueObjectConstructor< } /** - * Sentinel returned (in place of the extended constructor) when the schema - * transform produces a type that is *not* assignable to the parent's output. - * It is intentionally not constructable, so `class X extends extend(...) {}` - * fails to type-check on the offending call. + * Returned when an `extend` schema produces an output not assignable to the parent's. + * Not constructable, so misuse fails to type-check at the `class X extends ...` site. */ export type SchemaTransformOutputMismatchError = { __valueObjectError: 'Schema transform output must be assignable to the parent schema output' diff --git a/test/ValueObject.test.ts b/test/ValueObject.test.ts index 0a5f1c9..ffcc1ab 100644 --- a/test/ValueObject.test.ts +++ b/test/ValueObject.test.ts @@ -35,13 +35,6 @@ describe('ValueObject', () => { expect(email === output).toBe(true) }) - /** - * This is primarily to help reduce the burden of writing custom equality - * checkers in tests. Most test frameworks will check that each value of an - * object and the prototype match instead of strict equality. - * - * Adding bound functions to the prototype will cause this to fail. - */ it('should be equal according to vitests equality checker', () => { const email = 'value@object.com' const a = Email.fromJSON(email) @@ -136,13 +129,6 @@ describe('ValueObject', () => { expect(ym === output).toBe(true) }) - /** - * This is primarily to help reduce the burden of writing custom equality - * checkers in tests. Most test frameworks will check that each value of an - * object and the prototype match instead of strict equality. - * - * Adding bound functions to the prototype will cause this to fail. - */ it('should be equal according to vitests equality checker', () => { const ym = {year: 2023, month: 10} const a = YearMonth.fromJSON(ym) @@ -345,13 +331,6 @@ describe('ValueObject', () => { expect(user).toBe(output) }) - /** - * This is primarily to help reduce the burden of writing custom equality - * checkers in tests. Most test frameworks will check that each value of an - * object and the prototype match instead of strict equality. - * - * Adding bound functions to the prototype will cause this to fail. - */ it('should be equal according to vitests equality checker', () => { const user = { email: 'value@object.com', @@ -526,13 +505,6 @@ describe('ValueObject', () => { expect(pet === output).toBe(true) }) - /** - * This is primarily to help reduce the burden of writing custom equality - * checkers in tests. Most test frameworks will check that each value of an - * object and the prototype match instead of strict equality. - * - * Adding bound functions to the prototype will cause this to fail. - */ it('should be equal according to vitests equality checker', () => { const pet = {type: 'dog' as const, woofs: true} const a = Pet.fromJSON(pet) @@ -819,4 +791,109 @@ describe('ValueObject', () => { expect(factoryCalls).toBe(1) }) }) + + describe('clone()', () => { + it('should clone a primitive-backed value object', () => { + class Email extends ValueObject.define({ + id: 'Email', + schema: () => z.string().email(), + }) {} + + const a = Email.fromJSON('value@object.com') + const b = a.clone() + + expect(b).not.toBe(a) + expect(b).toBeInstanceOf(Email) + expect(b.props).toEqual(a.props) + expect(a.equals(b)).toBe(true) + }) + + it('should deep-clone object props so mutating the clone does not affect the original', () => { + class Address extends ValueObject.define({ + id: 'Address', + schema: () => + z.object({ + street: z.string(), + tags: z.array(z.string()), + }), + }) {} + + const a = Address.fromJSON({street: '1 Main St', tags: ['home', 'primary']}) + const b = a.clone() + + expect(b).not.toBe(a) + expect(b.props).not.toBe(a.props) + expect(b.props.tags).not.toBe(a.props.tags) + expect(b.props).toEqual(a.props) + + b.props.tags.push('mutated') + expect(a.props.tags).toEqual(['home', 'primary']) + }) + + it('should re-validate props through the schema when cloning', () => { + let parseCount = 0 + class Counted extends ValueObject.define({ + id: 'Counted', + schema: () => + z.string().transform((v) => { + parseCount++ + return v + }), + }) {} + + const a = Counted.fromJSON('hello') + const before = parseCount + a.clone() + expect(parseCount).toBeGreaterThan(before) + }) + + it('should preserve methods/getters defined on subclasses and return the subclass type', () => { + class Email extends ValueObject.define({ + id: 'Email', + schema: () => z.string().email(), + }) { + get domain() { + return this.props.split('@')[1] + } + } + + const a = Email.fromJSON('alice@google.com') + const b = a.clone() + + expect(b).toBeInstanceOf(Email) + expect(b.domain).toBe('google.com') + expectTypeOf(b).toEqualTypeOf() + }) + + it('should clone instances created via ValueObject.extend and return the extended type', () => { + class Email extends ValueObject.define({ + id: 'Email', + schema: () => z.string().email(), + }) { + get domain() { + return this.props.split('@')[1] + } + } + + class GoogleEmail extends ValueObject.extend(Email, { + id: 'GoogleEmail', + schema: (prev) => + prev.refine((s) => s.endsWith('@google.com'), 'must be a google email'), + }) { + get isWorkspace() { + return this.props.endsWith('@workspace.google.com') + } + } + + const a = GoogleEmail.fromJSON('alice@google.com') + const b = a.clone() + + expect(b).not.toBe(a) + expect(b).toBeInstanceOf(GoogleEmail) + expect(b).toBeInstanceOf(Email) + expect(b.domain).toBe('google.com') + expect(b.isWorkspace).toBe(false) + expect(a.equals(b)).toBe(true) + }) + }) }) diff --git a/test/equals.test.ts b/test/equals.test.ts index 12f7352..b1ba478 100644 --- a/test/equals.test.ts +++ b/test/equals.test.ts @@ -140,8 +140,7 @@ describe('ValueObject#equals', () => { // Direct comparison goes through the override. expect(a.props.email.equals(b.props.email)).toBe(true) - // And so does the parent comparison, because deepEquals dispatches to - // the inner equals() rather than comparing the strings itself. + // Parent comparison cascades through the inner override. expect(a.equals(b)).toBe(true) }) }) From b3769d9793e72e5e4fc72e8f29a0cee7bf19f9b2 Mon Sep 17 00:00:00 2001 From: James Apple Date: Wed, 8 Apr 2026 18:55:52 +1000 Subject: [PATCH 5/7] Equals hardening --- src/utils.ts | 38 ++++++++- src/value-object.ts | 14 +--- test/equals.test.ts | 196 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 14 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index f67efba..2c464e4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -2,6 +2,11 @@ import z from 'zod' export const ValueObjectIdSymbol = Symbol('ValueObjectId') export const RAW_SCHEMA_ACCESSOR_KEY = Symbol('RawSchemaKey') +/** + * Marker placed on the default `ValueObject#equals` (avoiding infinite + * recursion between `equals` and `deepEquals`). + */ +export const DEFAULT_EQUALS_SYMBOL = Symbol('defaultEquals') export function instanceOrConstruct(klass: any, schema: z.ZodType) { return z.any().transform((input, ctx) => { @@ -123,7 +128,13 @@ export type ToJSONOutput = T extends PrimitiveType /** * Deeply compares two values with the same semantics as `ValueObject#equals`. - * Handles primitives, plain objects, arrays, dates, and value objects (delegating to their `equals`). + * Handles primitives, plain objects, arrays, dates, and value objects. + * + * For two value objects this checks that the IDs match and then deeply + * compares their `props`. If either side has overridden `equals` (i.e. the + * function is not the default marker-tagged implementation) the override is + * called instead so domain-specific identity is honoured even when the value + * object is nested inside another structure. */ export function deepEquals(a: unknown, b: unknown): boolean { if (Object.is(a, b)) return true @@ -134,15 +145,34 @@ export function deepEquals(a: unknown, b: unknown): boolean { const bIsVO = ValueObjectIdSymbol in (b as object) if (aIsVO !== bIsVO) return false if (aIsVO) { - return (a as any).equals(b) + if ((a as any)[ValueObjectIdSymbol] !== (b as any)[ValueObjectIdSymbol]) { + return false + } + const aEquals = (a as any).equals + if ( + typeof aEquals === 'function' && + !(aEquals as any)[DEFAULT_EQUALS_SYMBOL] + ) { + return aEquals.call(a, b) + } + const bEquals = (b as any).equals + if ( + typeof bEquals === 'function' && + !(bEquals as any)[DEFAULT_EQUALS_SYMBOL] + ) { + return bEquals.call(b, a) + } + return deepEquals((a as any).props, (b as any).props) } if (a instanceof Date || b instanceof Date) { return a instanceof Date && b instanceof Date && a.getTime() === b.getTime() } - if (Array.isArray(a) || Array.isArray(b)) { - if (!Array.isArray(a) || !Array.isArray(b)) return false + const aIsArray = Array.isArray(a) + const bIsArray = Array.isArray(b) + if (aIsArray || bIsArray) { + if (!aIsArray || !bIsArray) return false if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) { if (!deepEquals(a[i], b[i])) return false diff --git a/src/value-object.ts b/src/value-object.ts index b63bddf..7b1be6b 100644 --- a/src/value-object.ts +++ b/src/value-object.ts @@ -1,5 +1,6 @@ import z from 'zod' import { + DEFAULT_EQUALS_SYMBOL, RAW_SCHEMA_ACCESSOR_KEY, ToJSONOutput, deepEquals, @@ -261,16 +262,7 @@ export function define< } equals(other: unknown): boolean { - if ((this as any) === other) return true - if (other === null || typeof other !== 'object') return false - if (!(ValueObjectIdSymbol in other)) return false - if ( - (other as any)[ValueObjectIdSymbol] !== - (this as any)[ValueObjectIdSymbol] - ) { - return false - } - return deepEquals(this.props, (other as any).props) + return deepEquals(this, other) } clone(): ValueObjectInstance { @@ -284,6 +276,8 @@ export function define< } } + ;(DefinedValueObject.prototype.equals as any)[DEFAULT_EQUALS_SYMBOL] = true + return DefinedValueObject as unknown as ValueObjectConstructor } diff --git a/test/equals.test.ts b/test/equals.test.ts index b1ba478..636fa30 100644 --- a/test/equals.test.ts +++ b/test/equals.test.ts @@ -29,6 +29,30 @@ describe('ValueObject#equals', () => { }) }) + describe('null and undefined props', () => { + class UndefinedVO extends ValueObject.define({ + id: 'UndefinedVO', + schema: () => z.object({field: z.undefined()}), + }) {} + + class NullVO extends ValueObject.define({ + id: 'NullVO', + schema: () => z.object({field: z.null()}), + }) {} + + it('UndefinedVO instances are equal to each other', () => { + const a = UndefinedVO.fromJSON({field: undefined}) + const b = UndefinedVO.fromJSON({field: undefined}) + expect(a.equals(b)).toBe(true) + }) + + it('NullVO instances are equal to each other', () => { + const a = NullVO.fromJSON({field: null}) + const b = NullVO.fromJSON({field: null}) + expect(a.equals(b)).toBe(true) + }) + }) + describe('Object-backed value objects', () => { class Address extends ValueObject.define({ id: 'Address', @@ -320,6 +344,178 @@ describe('ValueObject#equals', () => { }) }) + describe('deepEquals branch coverage via heterogeneous prop shapes', () => { + // Each pair below targets a specific branch in `deepEquals` that is hard + // to reach through the more "well-typed" tests above. The schemas use + // unions so the same value object can hold either side of a heterogeneous + // comparison. + + it('returns false when one VO has a null prop and the other has an object prop', () => { + // Targets the `a === null` short-circuit reached via recursion (the + // top-level `equals` already guards `this`, so this branch only fires + // when deepEquals recurses into the props). + class MaybeData extends ValueObject.define({ + id: 'MaybeData', + schema: () => z.union([z.null(), z.object({x: z.number()})]), + }) {} + + const a = MaybeData.fromJSON(null) + const b = MaybeData.fromJSON({x: 1}) + expect(a.equals(b)).toBe(false) + // And the symmetric direction (`b === null` branch). + expect(b.equals(a)).toBe(false) + }) + + it('returns false when one nested value is a value object and the other is a plain object', () => { + // Targets `aIsVO !== bIsVO` reached via recursion into props. + class Email extends ValueObject.define({ + id: 'Email', + schema: () => z.string().email(), + }) {} + + class Container extends ValueObject.define({ + id: 'Container', + schema: () => + z.object({ + inner: z.union([ + Email.schema(), + z.object({raw: z.string()}), + ]), + }), + }) {} + + const a = Container.fromJSON({inner: 'alice@example.com'}) + const b = Container.fromJSON({inner: {raw: 'alice@example.com'}}) + expect(a.equals(b)).toBe(false) + expect(b.equals(a)).toBe(false) + }) + + it('returns false when comparing two VOs with different IDs but the same prop shape', () => { + // Targets the inline ID-mismatch branch in deepEquals. + class A extends ValueObject.define({ + id: 'A', + schema: () => z.object({n: z.number()}), + }) {} + class B extends ValueObject.define({ + id: 'B', + schema: () => z.object({n: z.number()}), + }) {} + + const a = A.fromJSON({n: 1}) + const b = B.fromJSON({n: 1}) as any + expect(a.equals(b)).toBe(false) + }) + + it('returns false when one prop is a Date and the other is a plain object', () => { + // Targets the Date-vs-non-Date branch in the Date check. + class WhenOrMeta extends ValueObject.define({ + id: 'WhenOrMeta', + schema: () => + z.object({ + value: z.union([z.date(), z.object({y: z.number()})]), + }), + }) {} + + const a = WhenOrMeta.fromJSON({value: new Date('2024-01-01T00:00:00Z')}) + const b = WhenOrMeta.fromJSON({value: {y: 1}}) + expect(a.equals(b)).toBe(false) + expect(b.equals(a)).toBe(false) + }) + + it('returns false when one prop is an array and the other is a plain object', () => { + // Targets the array-vs-non-array branch. + class ListOrMap extends ValueObject.define({ + id: 'ListOrMap', + schema: () => + z.object({ + value: z.union([ + z.array(z.string()), + z.object({k: z.string()}), + ]), + }), + }) {} + + const a = ListOrMap.fromJSON({value: ['a', 'b']}) + const b = ListOrMap.fromJSON({value: {k: 'a'}}) + expect(a.equals(b)).toBe(false) + expect(b.equals(a)).toBe(false) + }) + + it('returns false when nested objects have a different number of keys', () => { + // Targets the `aKeys.length !== bKeys.length` branch on nested plain objects. + class WithOptional extends ValueObject.define({ + id: 'WithOptional', + schema: () => + z.object({ + data: z.object({ + a: z.string(), + b: z.string().optional(), + }), + }), + }) {} + + const a = WithOptional.fromJSON({data: {a: 'x', b: 'y'}}) + const b = WithOptional.fromJSON({data: {a: 'x'}}) + expect(a.equals(b)).toBe(false) + }) + + it('returns false when nested objects have the same key count but different keys', () => { + // Targets the `hasOwnProperty(b, key)` branch where lengths match but + // a key from `a` is missing in `b`. + class TwoShapes extends ValueObject.define({ + id: 'TwoShapes', + schema: () => + z.object({ + data: z.union([ + z.object({x: z.string()}), + z.object({y: z.string()}), + ]), + }), + }) {} + + const a = TwoShapes.fromJSON({data: {x: 'a'}}) + const b = TwoShapes.fromJSON({data: {y: 'a'}}) + expect(a.equals(b)).toBe(false) + }) + + it('honours an override that lives only on the right-hand operand', () => { + // Targets the `bEquals` override-detection branch in deepEquals. Both + // values must share the same VO id (so the inline ID check passes), but + // only the right-hand instance must carry an override — otherwise the + // `aEquals` branch fires first. We pin the override directly onto the + // single instance to engineer that asymmetry. + class Word extends ValueObject.define({ + id: 'Word', + schema: () => z.string(), + }) {} + + const a = Word.fromJSON('HELLO') + const b = Word.fromJSON('hello') + + // Default equals is case-sensitive — sanity check the starting point. + expect(a.equals(b)).toBe(false) + + // Pin a case-insensitive override on the `b` instance only. + ;(b as any).equals = function (other: Word) { + return this.props.toLowerCase() === other.props.toLowerCase() + } + + // a.equals(b) → deepEquals(a, b) → a uses default → b's override fires. + expect(a.equals(b)).toBe(true) + }) + + it('comparing a value object to a primitive returns false', () => { + // Targets the `typeof b !== "object"` branch where `this` is an object. + class Email extends ValueObject.define({ + id: 'Email', + schema: () => z.string().email(), + }) {} + const e = Email.fromJSON('alice@example.com') + expect((e.equals as any)(42)).toBe(false) + expect((e.equals as any)(true)).toBe(false) + }) + }) + describe('Date and primitive edge cases in props', () => { class Event extends ValueObject.define({ id: 'Event', From 2bf788d507c0e063317e70811fd1233339c64f0c Mon Sep 17 00:00:00 2001 From: James Apple Date: Wed, 8 Apr 2026 19:07:37 +1000 Subject: [PATCH 6/7] Coverage update --- src/union.ts | 17 ++--------------- src/utils.ts | 10 +--------- test/edge-cases.test.ts | 16 ++++++++++++++++ test/extends.test.ts | 7 +++++++ test/utils.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 5 files changed, 62 insertions(+), 24 deletions(-) diff --git a/src/union.ts b/src/union.ts index 3537a79..240bf80 100644 --- a/src/union.ts +++ b/src/union.ts @@ -186,14 +186,9 @@ export function defineUnion< const type = typeSchema.safeParse(value) if (!type.success) { - const currentPath = - 'path' in ctx && Array.isArray((ctx as any).path) - ? (ctx as any).path - : [] - ctx.addIssue({ code: 'custom', - path: [...currentPath, discriminator], + path: [discriminator], }) return z.NEVER } @@ -203,15 +198,7 @@ export function defineUnion< if (!parsed.success) { for (const issue of parsed.error.issues) { - const currentPath = - 'path' in ctx && Array.isArray((ctx as any).path) - ? (ctx as any).path - : [] - - ctx.addIssue({ - ...issue, - path: [...currentPath, ...(issue.path ?? [])], - }) + ctx.addIssue({...issue}) } return z.NEVER } diff --git a/src/utils.ts b/src/utils.ts index 2c464e4..85963e4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -17,15 +17,7 @@ export function instanceOrConstruct(klass: any, schema: z.ZodType) { const result = schema.safeParse(input) if (!result.success) { for (const issue of result.error.issues) { - const currentPath = - 'path' in ctx && Array.isArray((ctx as any).path) - ? (ctx as any).path - : [] - - ctx.addIssue({ - ...issue, - path: [...currentPath, ...(issue.path ?? [])], - }) + ctx.addIssue({...issue}) } return z.NEVER } diff --git a/test/edge-cases.test.ts b/test/edge-cases.test.ts index 728b4e2..f2f525d 100644 --- a/test/edge-cases.test.ts +++ b/test/edge-cases.test.ts @@ -189,6 +189,22 @@ describe('Edge Cases and Error Handling', () => { }).toThrow('Field "type" is not a ZodLiteral in the provided schema.') }) + it('should throw when defining a union with no member types', () => { + const EmptyUnion = ValueObject.defineUnion('type', () => ({} as any)) + expect(() => EmptyUnion.fromJSON({type: 'whatever'} as any)).toThrow( + 'Union must have at least one type', + ) + }) + + it('should support a union with a single member type', () => { + const Solo = ValueObject.defineUnion('type', () => ({dog: Dog})) + const parsed = Solo.fromJSON({type: 'dog', woofs: true}) + expect(parsed).toBeInstanceOf(Dog) + // The non-matching discriminator path is also exercised so we're sure + // the single-literal branch produced a working schema. + expect(() => Solo.fromJSON({type: 'cat', meows: true} as any)).toThrow() + }) + it('should handle union with non-object schemas', () => { class StringVO extends ValueObject.define({ id: 'StringVO', diff --git a/test/extends.test.ts b/test/extends.test.ts index ed75ca6..9c04289 100644 --- a/test/extends.test.ts +++ b/test/extends.test.ts @@ -258,6 +258,13 @@ describe('ValueObject.extends', () => { type S = ValueObject.ValueObjectSchema expectTypeOf>().toEqualTypeOf() }) + + it('exposes schemaRaw() on the extended class', () => { + const raw = (GoogleEmail as any).schemaRaw() + // It is a Zod schema and rejects non-google emails (the extended rule). + expect(raw.safeParse('a@yahoo.com').success).toBe(false) + expect(raw.safeParse('a@google.com').success).toBe(true) + }) }) describe('Type-level constraint on schema transform output', () => { diff --git a/test/utils.test.ts b/test/utils.test.ts index 269e69d..13668c1 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -162,6 +162,15 @@ describe('Utils', () => { expect(recursivelyToJSON(obj)).toEqual(obj) }) + it('returns the value unchanged for non-object, non-primitive types', () => { + const fn = () => 'noop' + expect(recursivelyToJSON(fn as any)).toBe(fn) + const sym = Symbol('s') + expect(recursivelyToJSON(sym as any)).toBe(sym) + const big = BigInt(10) + expect(recursivelyToJSON(big as any)).toBe(big) + }) + it('should handle recursive toJSON calls', () => { const obj = { toJSON() { @@ -265,6 +274,17 @@ describe('Utils', () => { 'ZodLiteral value for field "type" must be a string.' ) }) + + it('should throw error for ZodLiteral with multiple values', () => { + const schema = z.object({ + type: z.literal(['dog', 'cat']), + name: z.string() + }) + + expect(() => extractZodLiteralValueFromObjectSchema(schema, 'type')).toThrow( + 'ZodLiteral for field "type" must have exactly one value.' + ) + }) }) describe('instanceOrConstruct()', () => { @@ -307,6 +327,22 @@ describe('Utils', () => { expect(() => transformer.parse('test')).toThrow() }) + it('should fall back to "Invalid input" when constructor throws a non-Error', () => { + class FailingNonError { + constructor() { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw 'plain string failure' + } + } + + const transformer = instanceOrConstruct(FailingNonError, z.string()) + const result = transformer.safeParse('test') + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('Invalid input') + } + }) + it('should preserve error paths in nested contexts', () => { const transformer = instanceOrConstruct(TestVO, z.string()) const nestedSchema = z.object({ From 0a689957aafadc68351f5e90da30c5bcf023367a Mon Sep 17 00:00:00 2001 From: James Apple Date: Wed, 8 Apr 2026 19:28:43 +1000 Subject: [PATCH 7/7] Union cleanup --- README.md | 23 +++---- src/union.ts | 130 ++++++++++++++------------------------- src/utils.ts | 2 +- test/ValueObject.test.ts | 49 +++------------ test/edge-cases.test.ts | 71 +++++++-------------- test/extends.test.ts | 13 +--- test/integration.test.ts | 23 +++---- test/types.test.ts | 9 +-- 8 files changed, 102 insertions(+), 218 deletions(-) diff --git a/README.md b/README.md index 3ec2dd8..d3bdab0 100644 --- a/README.md +++ b/README.md @@ -340,14 +340,11 @@ class Square extends ValueObject.define({ } } -const Shape = ValueObject.defineUnion('kind', () => ({ - circle: Circle, - square: Square, -})) +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) +shape instanceof Circle // true +Shape.isInstance(Circle, shape) // true (with type narrowing) // Use it inside any other Zod schema const drawingSchema = z.object({ @@ -356,7 +353,7 @@ const drawingSchema = z.object({ }) ``` -The discriminator literal on each member is checked against the key at the type level — keying `Circle` under anything other than `'circle'` is a compile-time error. +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 @@ -454,14 +451,14 @@ The schema's output type must remain assignable to the parent's output type, or ### `ValueObject.defineUnion(discriminator, members)` -Creates a discriminated union of value objects. +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` | `() => Record` | Map of discriminator literal → member class | +| 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()` methods. +Returns an object with `fromJSON()`, `schema()`, and `isInstance(ctor, value)` methods. `isInstance` narrows the value to the given constructor's instance type. ### Instance members diff --git a/src/union.ts b/src/union.ts index 240bf80..8f39cd5 100644 --- a/src/union.ts +++ b/src/union.ts @@ -15,6 +15,7 @@ export type ValueObjectSchema = T extends ValueObjectConstructor< : T extends ValueObjectInstance ? Z : never + export type ValueObjectInst = T extends ValueObjectConstructor< string, any, @@ -25,61 +26,39 @@ export type ValueObjectInst = T extends ValueObjectConstructor< ? T : never -export type UnionInput< - T extends Record>, -> = { - [K in keyof T]: z.input> | ValueObjectInst -}[keyof T] - -export type UnionOutput< - T extends Record>, -> = { - [K in keyof T]: ValueObjectInst -}[keyof T] +export type UnionMembers = readonly ValueObjectConstructor[] -type DiscriminatorOf = C extends ValueObjectConstructor< - string, - infer Z, - any -> - ? z.output extends { [P in D]: infer V } - ? V - : never - : never +export type UnionInput = + | { + [K in keyof Members]: z.input> + }[number] + | { + [K in keyof Members]: ValueObjectInst + }[number] -type ValidatedUnionMembers< - D extends string, - T extends Record>, -> = { - [K in keyof T]: K extends DiscriminatorOf - ? unknown - : { - DISCRIMINATOR_MISMATCH: `Schema discriminator literal does not match key "${K & - string}"` - } -} +export type UnionOutput = { + [K in keyof Members]: ValueObjectInst +}[number] -export interface ValueObjectUnion< - T extends Record>, -> { +export interface ValueObjectUnion { /** * Zod schema for the union; accepts any member's input or instance and returns the matching instance. * * @example * z.object({ pet: Pets.schema() }).parse({ pet: { type: 'dog', woofs: true } }) */ - schema(): z.ZodCustom, UnionInput> + schema(): z.ZodCustom, UnionInput> /** - * Type guard for a specific member of the union. + * Type guard for a specific member of the union, narrowed by the constructor reference. * * @example - * if (Pets.isInstance('dog', pet)) pet.props.woofs + * if (Pets.isInstance(Dog, pet)) pet.props.woofs */ - isInstance( - discriminator: K, + isInstance( + ctor: C, value: unknown, - ): value is ValueObjectInst + ): value is InstanceType /** * Parses raw input into the matching member instance. @@ -87,13 +66,13 @@ export interface ValueObjectUnion< * @example * const pet = Pets.fromJSON({ type: 'cat', sharpClaws: false }) // Cat */ - fromJSON(input: UnionInput): UnionOutput + fromJSON(input: UnionInput): UnionOutput } /** * Creates a discriminated union of value objects. Each member must use a - * `z.literal()` for the discriminator field, and the literal value must match - * the key in the values record. + * `z.literal()` for the discriminator field; the literal value is read directly + * from the schema, so members are passed as a plain array. * * @example * class Dog extends ValueObject.define({ @@ -106,39 +85,36 @@ export interface ValueObjectUnion< * schema: () => z.object({ type: z.literal('cat'), sharpClaws: z.boolean() }), * }) {} * - * const Pets = ValueObject.defineUnion('type', () => ({ dog: Dog, cat: Cat })) + * const Pets = ValueObject.defineUnion('type', [Dog, Cat]) * * const pet = Pets.fromJSON({ type: 'dog', woofs: true }) // Dog | Cat - * if (Pets.isInstance('dog', pet)) pet.props.woofs + * if (Pets.isInstance(Dog, pet)) pet.props.woofs */ -export function defineUnion< - D extends string, - T extends Record>, ->( +export function defineUnion( discriminator: D, - values: () => T & ValidatedUnionMembers, -): ValueObjectUnion { - const getValues = once(values) - - const validate = once(() => { - Object.entries(getValues()).forEach(([discriminatorValue, ctor]) => { + members: Members, +): ValueObjectUnion { + const buildIndex = once(() => { + const map = new Map>() + for (const ctor of members) { const schema = extractSchema(ctor) - - const instanceDiscriminator = extractZodLiteralValueFromObjectSchema( + const literal = extractZodLiteralValueFromObjectSchema( schema, discriminator, ) - if (instanceDiscriminator !== discriminatorValue) { + if (map.has(literal)) { throw new Error( - `Discriminator value mismatch for ${ctor.name}: expected "${discriminatorValue}", got "${instanceDiscriminator}"`, + `Duplicate discriminator value "${literal}" in union for "${discriminator}"`, ) } - }) + map.set(literal, ctor) + } + return map }) const getTypeSchema = once(() => { - validate() - const types = Object.keys(getValues()) + const map = buildIndex() + const types = Array.from(map.keys()) if (types.length === 0) { throw new Error('Union must have at least one type') @@ -161,25 +137,12 @@ export function defineUnion< .transform((value) => value[discriminator] as string) }) - const requireCtor = (key: string) => { - validate() - const values = getValues() - const ctor = values[key] - - if (!ctor) { - throw new Error(`No schema found for discriminator value "${key}"`) - } - - return ctor - } - const getSchema = once(() => { - const allSchemas = Object.values(getValues()) - validate() + const map = buildIndex() return z.preprocess((value, ctx) => { - if (allSchemas.some((klass) => value instanceof klass)) { - return value + for (const ctor of members) { + if (value instanceof ctor) return value } const typeSchema = getTypeSchema() @@ -193,12 +156,12 @@ export function defineUnion< return z.NEVER } - const ctor = requireCtor(type.data) + const ctor = map.get(type.data)! const parsed = ctor.schema().safeParse(value) if (!parsed.success) { for (const issue of parsed.error.issues) { - ctx.addIssue({...issue}) + ctx.addIssue({ ...issue }) } return z.NEVER } @@ -207,15 +170,16 @@ export function defineUnion< }) return { - isInstance(discriminator: string, value: unknown): boolean { - return value instanceof requireCtor(discriminator) + isInstance(ctor: ValueObjectConstructor, value: unknown) { + buildIndex() + return value instanceof ctor }, schema() { return getSchema() }, - fromJSON(input: unknown): UnionOutput { + fromJSON(input: unknown): UnionOutput { return getSchema().parse(input) }, } as any diff --git a/src/utils.ts b/src/utils.ts index 85963e4..d981dce 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -17,7 +17,7 @@ export function instanceOrConstruct(klass: any, schema: z.ZodType) { const result = schema.safeParse(input) if (!result.success) { for (const issue of result.error.issues) { - ctx.addIssue({...issue}) + ctx.addIssue({ ...issue }) } return z.NEVER } diff --git a/test/ValueObject.test.ts b/test/ValueObject.test.ts index ffcc1ab..44a422b 100644 --- a/test/ValueObject.test.ts +++ b/test/ValueObject.test.ts @@ -584,16 +584,9 @@ describe('ValueObject', () => { }) }) {} - const Pets = ValueObject.defineUnion('type', () => ({ - cat: Cat, - dog: Dog, - })) + const Pets = ValueObject.defineUnion('type', [Cat, Dog]) - const ExtendedPets = ValueObject.defineUnion('type', () => ({ - cat: Cat, - dog: Dog, - bird: Bird, - })) + const ExtendedPets = ValueObject.defineUnion('type', [Cat, Dog, Bird]) it('should parse', () => { const pet = Pets.fromJSON({type: 'dog', woofs: true}) @@ -601,10 +594,10 @@ describe('ValueObject', () => { expect(pet.props).toEqual({type: 'dog', woofs: true}) expect(JSON.stringify(pet)).toEqual('{"type":"dog","woofs":true}') expect(pet.toJSON()).toEqual({type: 'dog', woofs: true}) - if (!Pets.isInstance('dog', pet)) { + if (!Pets.isInstance(Dog, pet)) { expect.fail('Expected pet to be an instance of Dog') } - if (Pets.isInstance('cat', pet)) { + if (Pets.isInstance(Cat, pet)) { expect.fail('Expected pet to not be an instance of Cat') } expect(Pets.schema().parse(pet.toJSON())).toEqual(pet) @@ -702,9 +695,9 @@ describe('ValueObject', () => { expect(bird).toBeInstanceOf(Bird) expect(bird.props).toEqual({type: 'bird', flies: true, species: 'eagle'}) - expect(ExtendedPets.isInstance('bird', bird)).toBe(true) - expect(ExtendedPets.isInstance('dog', bird)).toBe(false) - expect(ExtendedPets.isInstance('cat', bird)).toBe(false) + expect(ExtendedPets.isInstance(Bird, bird)).toBe(true) + expect(ExtendedPets.isInstance(Dog, bird)).toBe(false) + expect(ExtendedPets.isInstance(Cat, bird)).toBe(false) }) it('should handle nested value objects in union members', () => { @@ -733,10 +726,7 @@ describe('ValueObject', () => { }) }) {} - const Beings = ValueObject.defineUnion('type', () => ({ - person: PersonDog, - animal: SimpleCat - })) + const Beings = ValueObject.defineUnion('type', [PersonDog, SimpleCat]) const person = Beings.fromJSON({ type: 'person', @@ -752,11 +742,6 @@ describe('ValueObject', () => { } }) - it('should throw error for unknown discriminator in isInstance', () => { - expect(() => Pets.isInstance('unknown' as any, new Dog({type: 'dog', woofs: true}))) - .toThrow('No schema found for discriminator value "unknown"') - }) - it('should handle complex error scenarios in union parsing', () => { const result = ExtendedPets.schema().safeParse({ type: 'bird', @@ -772,24 +757,6 @@ describe('ValueObject', () => { } }) - it('should validate union factory is called only once', () => { - let factoryCalls = 0 - - const LazyUnion = ValueObject.defineUnion('type', () => { - factoryCalls++ - return { - dog: Dog, - cat: Cat - } - }) - - LazyUnion.fromJSON({type: 'dog', woofs: true}) - LazyUnion.fromJSON({type: 'cat', sharpClaws: false}) - LazyUnion.schema() - LazyUnion.isInstance('dog', new Dog({type: 'dog', woofs: true})) - - expect(factoryCalls).toBe(1) - }) }) describe('clone()', () => { diff --git a/test/edge-cases.test.ts b/test/edge-cases.test.ts index f2f525d..d4d5320 100644 --- a/test/edge-cases.test.ts +++ b/test/edge-cases.test.ts @@ -102,42 +102,23 @@ describe('Edge Cases and Error Handling', () => { }) }) {} - it('should reject mismatched discriminator values at the type level', () => { - ValueObject.defineUnion('type', () => ({ - // @ts-expect-error - Dog's discriminator literal is "dog", not "wrongKey" - wrongKey: Dog, - cat: Cat, - })) - - ValueObject.defineUnion('type', () => ({ - // @ts-expect-error - Cat's discriminator literal is "cat", not "dog" - dog: Cat, - // @ts-expect-error - Dog's discriminator literal is "dog", not "cat" - cat: Dog, - })) - - ValueObject.defineUnion('type', () => ({ - // @ts-expect-error - "spaghetti" is not a valid discriminator for Dog - spaghetti: Dog, - // @ts-expect-error - "bolognese" is not a valid discriminator for Cat - bolognese: Cat, - })) - - // Runtime guard still rejects mismatches that bypass the type system. - const MismatchedUnion = ValueObject.defineUnion('type', () => ({ - wrongKey: Dog, - cat: Cat, - }) as any) - - expect(() => MismatchedUnion.fromJSON({type: 'dog', woofs: true} as any)) - .toThrow('Discriminator value mismatch for Dog: expected "wrongKey", got "dog"') + it('should throw at runtime when two members share a discriminator literal', () => { + class DogTwin extends ValueObject.define({ + id: 'DogTwin', + schema: () => z.object({ + type: z.literal('dog'), + name: z.string() + }) + }) {} + + const Dupes = ValueObject.defineUnion('type', [Dog, DogTwin]) + expect(() => Dupes.fromJSON({type: 'dog', woofs: true})).toThrow( + 'Duplicate discriminator value "dog" in union for "type"', + ) }) it('should handle invalid discriminator values in union', () => { - const Pets = ValueObject.defineUnion('type', () => ({ - dog: Dog, - cat: Cat - })) + const Pets = ValueObject.defineUnion('type', [Dog, Cat]) expect(() => Pets.fromJSON({type: 'bird', flies: true} as any)) .toThrowErrorMatchingInlineSnapshot(` @@ -154,10 +135,7 @@ describe('Edge Cases and Error Handling', () => { }) it('should handle missing discriminator field', () => { - const Pets = ValueObject.defineUnion('type', () => ({ - dog: Dog, - cat: Cat - })) + const Pets = ValueObject.defineUnion('type', [Dog, Cat]) expect(() => Pets.fromJSON({woofs: true} as any)).toThrowErrorMatchingInlineSnapshot(` "[ @@ -182,22 +160,20 @@ describe('Edge Cases and Error Handling', () => { }) {} expect(() => { - const BadUnion = ValueObject.defineUnion('type', () => ({ - invalid: InvalidVO - })) - BadUnion.fromJSON({type: 'invalid', value: 'test'}) + const BadUnion = ValueObject.defineUnion('type', [InvalidVO]) + BadUnion.fromJSON({type: 'invalid', value: 'test'} as any) }).toThrow('Field "type" is not a ZodLiteral in the provided schema.') }) it('should throw when defining a union with no member types', () => { - const EmptyUnion = ValueObject.defineUnion('type', () => ({} as any)) - expect(() => EmptyUnion.fromJSON({type: 'whatever'} as any)).toThrow( + const EmptyUnion = ValueObject.defineUnion('type', [] as const) as any + expect(() => EmptyUnion.fromJSON({type: 'whatever'})).toThrow( 'Union must have at least one type', ) }) it('should support a union with a single member type', () => { - const Solo = ValueObject.defineUnion('type', () => ({dog: Dog})) + const Solo = ValueObject.defineUnion('type', [Dog]) const parsed = Solo.fromJSON({type: 'dog', woofs: true}) expect(parsed).toBeInstanceOf(Dog) // The non-matching discriminator path is also exercised so we're sure @@ -212,11 +188,8 @@ describe('Edge Cases and Error Handling', () => { }) {} expect(() => { - const BadUnion = ValueObject.defineUnion('type', () => ({ - // @ts-expect-error - StringVO's schema is not an object with a discriminator field - string: StringVO - })) - BadUnion.fromJSON('test') + const BadUnion = ValueObject.defineUnion('type', [StringVO]) + BadUnion.fromJSON('test' as any) }).toThrow('Cannot extract ZodLiteral value from non-object schema at type.') }) }) diff --git a/test/extends.test.ts b/test/extends.test.ts index 9c04289..5fa69f7 100644 --- a/test/extends.test.ts +++ b/test/extends.test.ts @@ -231,10 +231,7 @@ describe('ValueObject.extends', () => { schema: (prev) => prev.refine((d) => d.age < 1, 'must be under 1 year'), }) {} - const Pets = ValueObject.defineUnion('type', () => ({ - dog: Puppy, - cat: Cat, - })) + const Pets = ValueObject.defineUnion('type', [Puppy, Cat]) // Parses through the *extended* validation: an old dog is rejected. const puppy = Pets.fromJSON({type: 'dog', age: 0.5, name: 'Rex'}) @@ -244,14 +241,6 @@ describe('ValueObject.extends', () => { expect(() => Pets.fromJSON({type: 'dog', age: 5, name: 'Rex'}), ).toThrow(/must be under 1 year/) - - // The discriminator validator on defineUnion uses the inherited 'dog' - // literal — keying Puppy under any other label is a type error. - ValueObject.defineUnion('type', () => ({ - // @ts-expect-error - Puppy's discriminator literal is "dog", not "puppy" - puppy: Puppy, - cat: Cat, - })) }) it('ValueObjectSchema resolves to the extended schema', () => { diff --git a/test/integration.test.ts b/test/integration.test.ts index ebf599b..acb3d4b 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -73,11 +73,11 @@ describe('Integration Tests - Real-world Scenarios', () => { }) }) {} - const ProductVariant = ValueObject.defineUnion('type', () => ({ - physical: PhysicalProduct, - digital: DigitalProduct, - service: ServiceProduct - })) + const ProductVariant = ValueObject.defineUnion('type', [ + PhysicalProduct, + DigitalProduct, + ServiceProduct, + ]) // Complex entities with nested value objects class Product extends ValueObject.define({ @@ -396,10 +396,10 @@ describe('Integration Tests - Real-world Scenarios', () => { }) }) {} - const DomainEvent = ValueObject.defineUnion('type', () => ({ - user_registered: UserRegistered, - order_placed: OrderPlaced - })) + const DomainEvent = ValueObject.defineUnion('type', [ + UserRegistered, + OrderPlaced, + ]) class EventStore extends ValueObject.define({ id: 'EventStore', @@ -480,10 +480,7 @@ describe('Integration Tests - Real-world Scenarios', () => { }) }) {} - const ApiResponse = ValueObject.defineUnion('status', () => ({ - success: ApiSuccess, - error: ApiError - })) + const ApiResponse = ValueObject.defineUnion('status', [ApiSuccess, ApiError]) it('should model API responses with proper discrimination', () => { const successResponse = ApiResponse.fromJSON({ diff --git a/test/types.test.ts b/test/types.test.ts index 5c15b2d..e4c4b4c 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -299,21 +299,18 @@ describe('Type Inference Tests', () => { }) }) {} - const Pets = ValueObject.defineUnion('type', () => ({ - dog: Dog, - cat: Cat - })) + const Pets = ValueObject.defineUnion('type', [Dog, Cat]) it('should infer union output types', () => { const pet = Pets.fromJSON({type: 'dog', woofs: true}) expectTypeOf(pet).toEqualTypeOf() - expectTypeOf>() + expectTypeOf>() .toEqualTypeOf() }) it('should infer union input types', () => { - expectTypeOf>() + expectTypeOf>() .toEqualTypeOf< {type: 'dog', woofs: boolean} | Dog | {type: 'cat', meows: boolean} | Cat