• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

unruly-software / value-object / 24124497168

08 Apr 2026 07:57AM UTC coverage: 92.617% (+0.8%) from 91.837%
24124497168

Pull #12

github

JamesApple
Final update for v2
Pull Request #12: Value object v2 definition and documentation

106 of 124 branches covered (85.48%)

Branch coverage included in aggregate %.

50 of 51 new or added lines in 2 files covered. (98.04%)

2 existing lines in 1 file now uncovered.

170 of 174 relevant lines covered (97.7%)

91.74 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.36
/src/value-object.ts
1
import z from 'zod'
2
import {
3
  RAW_SCHEMA_ACCESSOR_KEY,
4
  ToJSONOutput,
5
  deepEquals,
6
  instanceOrConstruct,
7
  once,
8
  recursivelyToJSON,
9
  ValueObjectIdSymbol,
10
} from './utils'
11

12
/**
13
 * Infers the serialized JSON shape of a value object (the return type of `toJSON()`).
14
 *
15
 * @example
16
 * type EmailJSON = ValueObject.inferJSON<typeof Email> // string
17
 */
18
export type inferJSON<T> = T extends ValueObjectConstructor<
19
  string,
20
  any,
21
  infer JS
22
>
23
  ? JS
24
  : T extends ValueObjectInstance<string, any, infer JS>
25
  ? JS
26
  : never
27

28
/**
29
 * Infers the parsed `props` shape of a value object (the schema's output type).
30
 *
31
 * @example
32
 * type YearMonthProps = ValueObject.inferProps<typeof YearMonth> // { year: number, month: number }
33
 */
34
export type inferProps<T> = T extends ValueObjectConstructor<
35
  string,
36
  infer Z,
37
  any
38
>
39
  ? z.output<Z>
40
  : T extends ValueObjectInstance<string, infer Z, any>
41
  ? z.output<Z>
42
  : never
43

44
/**
45
 * Infers the accepted input type of a value object — either the raw schema input or an existing instance.
46
 *
47
 * @example
48
 * type EmailInput = ValueObject.inferInput<typeof Email> // string | Email
49
 */
50
export type inferInput<T> = T extends ValueObjectConstructor<
51
  string,
52
  infer Z,
53
  any
54
>
55
  ? z.input<Z> | InstanceType<T>
56
  : T extends ValueObjectInstance<string, infer Z, any>
57
  ? z.input<Z> | T
58
  : never
59

60
/**
61
 * Infers the raw schema input — the same as `inferInput<T>` but excluding the instance type.
62
 *
63
 * @example
64
 * type EmailRaw = ValueObject.inferRawInput<typeof Email> // string
65
 */
66
export type inferRawInput<T> = T extends ValueObjectConstructor<
67
  string,
68
  infer Z,
69
  any
70
>
71
  ? z.input<Z>
72
  : T extends ValueObjectInstance<string, infer Z, any>
73
  ? z.input<Z>
74
  : never
75

76
export interface ValueObjectInstance<
77
  ID extends string,
78
  T extends z.ZodTypeAny,
79
  JS,
80
> {
81
  [ValueObjectIdSymbol]: ID
82

83
  readonly props: z.output<T>
84

85
  readonly __schema: T
86

87
  /**
88
   * JSON-compatible representation of the value object. Honours the optional
89
   * `toJSON` serializer passed to `define()` / `extend()`.
90
   *
91
   * @example
92
   * Email.fromJSON('alice@example.com').toJSON() // 'alice@example.com'
93
   */
94
  toJSON(): ToJSONOutput<JS>
95

96
  /**
97
   * Structural equality — same type and deeply-equal `props`. Override on a
98
   * subclass to express domain-specific identity (e.g. comparing only `id`).
99
   *
100
   * @example
101
   * class User extends ValueObject.define({
102
   *   id: 'User',
103
   *   schema: () => z.object({ id: z.string(), name: z.string() }),
104
   * }) {
105
   *   override equals(other: User) { return this.props.id === other.props.id }
106
   * }
107
   */
108
  equals(other: this): boolean
109

110
  /**
111
   * Returns a duplicate instance by re-parsing `props` through the schema
112
   * (Zod handles deep cloning) and constructing a new instance of the same class.
113
   *
114
   * @example
115
   * const a = Address.fromJSON({ street: '1 Main St', tags: ['home'] })
116
   * const b = a.clone()
117
   * b.props.tags.push('mutated')
118
   * a.props.tags // ['home'] — original is untouched
119
   */
120
  clone(): this
121
}
122

123
export interface ValueObjectConstructor<
124
  ID extends string,
125
  T extends z.ZodTypeAny,
126
  JS,
127
> {
128
  [ValueObjectIdSymbol]: ID
129

130
  /**
131
   * Zod schema accepting either a raw input or an existing instance, returning
132
   * an instance. Use this when composing the value object inside other Zod schemas.
133
   *
134
   * @example
135
   * const Form = z.object({ email: Email.schema() })
136
   * Form.parse({ email: 'a@b.com' }).email instanceof Email // true
137
   */
138
  schema<CTOR extends ValueObjectConstructor<ID, T, JS>>(
139
    this: CTOR,
140
  ): z.ZodUnion<
141
    [
142
      z.ZodPipe<T, z.ZodTransform<InstanceType<CTOR>, T>>,
143
      z.ZodCustom<InstanceType<CTOR>, InstanceType<CTOR>>,
144
    ]
145
  >
146

147
  /**
148
   * Zod schema accepting only the raw primitive input (not an instance),
149
   * returning an instance. Useful when parsing JSON from the wire.
150
   *
151
   * @example
152
   * Email.schemaPrimitive().parse('a@b.com') instanceof Email // true
153
   */
154
  schemaPrimitive<CTOR extends ValueObjectConstructor<ID, T, JS>>(
155
    this: CTOR,
156
  ): z.ZodPipe<T, z.ZodTransform<InstanceType<CTOR>, T>>
157

158
  /**
159
   * The raw underlying Zod schema with no instance wrapping.
160
   *
161
   * @example
162
   * Email.schemaRaw().parse('a@b.com') // 'a@b.com' (string, not Email)
163
   */
164
  schemaRaw<CTOR extends ValueObjectConstructor<ID, T, JS>>(this: CTOR): T
165

166
  /**
167
   * Parses a raw input (or accepts an existing instance) and returns a validated instance.
168
   *
169
   * @example
170
   * const email = Email.fromJSON('a@b.com')
171
   * Email.fromJSON(email) === email // true — instances pass through
172
   */
173
  fromJSON<CTOR extends ValueObjectConstructor<ID, T, JS>>(
174
    this: CTOR,
175
    props: z.input<T> | InstanceType<CTOR>,
176
  ): InstanceType<CTOR>
177

178
  new (props: z.output<T>): ValueObjectInstance<ID, T, JS>
179
}
180

181
/**
182
 * Creates a value object class backed by a Zod schema. Extend the returned class
183
 * to add methods/getters. The class exposes `fromJSON`, `schema`, `schemaPrimitive`
184
 * and `schemaRaw` statics, plus `props` and `toJSON()` on instances.
185
 *
186
 * @example
187
 * class Email extends ValueObject.define({
188
 *   id: 'Email',
189
 *   schema: () => z.string().email(),
190
 * }) {}
191
 *
192
 * const email = Email.fromJSON('value@object.com')
193
 * email.props // 'value@object.com'
194
 * email.toJSON() // 'value@object.com'
195
 *
196
 * @example
197
 * // With a custom JSON serializer:
198
 * class YearMonth extends ValueObject.define({
199
 *   id: 'YearMonth',
200
 *   schema: () => z.object({ year: z.number(), month: z.number() }),
201
 *   toJSON: (v) => `${v.year}-${String(v.month).padStart(2, '0')}`,
202
 * }) {}
203
 */
204
export function define<
205
  ID extends string,
206
  T extends z.ZodTypeAny,
207
  JS = z.output<T>,
208
>(options: {
209
  id: ID
210
  schema: () => T
211
  toJSON?: (value: z.output<T>) => JS
212
}): ValueObjectConstructor<ID, T, JS> {
213
  const { id } = options
125✔
214
  const getSchema = once(options.schema)
125✔
215

216
  const schema = once(function (klass: ValueObjectConstructor<ID, T, JS>) {
125✔
217
    return instanceOrConstruct(klass, getSchema())
70✔
218
  })
219

220
  const schemaPrimitive = once(function (
125✔
221
    klass: ValueObjectConstructor<ID, T, JS>,
222
  ) {
223
    return getSchema().transform((value) => {
10✔
224
      return new klass(value)
7✔
225
    })
226
  })
227

228
  const DefinedValueObject = class {
125✔
229
    static [ValueObjectIdSymbol] = id
230
    static get [RAW_SCHEMA_ACCESSOR_KEY]() {
231
      return getSchema()
71✔
232
    }
233
    [ValueObjectIdSymbol] = id
285✔
234

235
    constructor(public readonly props: z.output<T>) {}
285✔
236

237
    static schema(this: ValueObjectConstructor<ID, T, JS>) {
238
      return schema(this)
193✔
239
    }
240

241
    static schemaPrimitive(this: ValueObjectConstructor<ID, T, JS>) {
242
      return schemaPrimitive(this)
19✔
243
    }
244

245
    static schemaRaw(this: ValueObjectConstructor<ID, T, JS>) {
246
      return getSchema()
14✔
247
    }
248

249
    static fromJSON(
250
      this: ValueObjectConstructor<ID, T, JS>,
251
      props: z.input<T>,
252
    ) {
253
      return this.schema().parse(props)
111✔
254
    }
255

256
    toJSON(): ToJSONOutput<JS> {
257
      if (options.toJSON) {
87✔
258
        return recursivelyToJSON(options.toJSON(this.props))
26✔
259
      }
260
      return recursivelyToJSON(this.props) as ToJSONOutput<JS>
61✔
261
    }
262

263
    equals(other: unknown): boolean {
264
      if ((this as any) === other) return true
37✔
265
      if (other === null || typeof other !== 'object') return false
36✔
266
      if (!(ValueObjectIdSymbol in other)) return false
33✔
267
      if (
32✔
268
        (other as any)[ValueObjectIdSymbol] !==
269
        (this as any)[ValueObjectIdSymbol]
270
      ) {
271
        return false
4✔
272
      }
273
      return deepEquals(this.props, (other as any).props)
28✔
274
    }
275

276
    clone(): ValueObjectInstance<ID, T, JS> {
277
      const Ctor = this.constructor as new (
5✔
278
        props: z.output<T>,
279
      ) => ValueObjectInstance<ID, T, JS>
280
      const cloned = (
281
        Ctor as unknown as ValueObjectConstructor<ID, T, JS>
5✔
282
      ).fromJSON(this.props as any)
283
      return cloned
5✔
284
    }
285
  }
286

287
  return DefinedValueObject as unknown as ValueObjectConstructor<ID, T, JS>
125✔
288
}
289

290
/** Extracts the Zod schema type from a value object constructor. */
291
type SchemaOf<P> = P extends ValueObjectConstructor<string, infer Z, any>
292
  ? Z
293
  : never
294

295
/** Methods/getters defined on the parent class, excluding structural members. */
296
type ParentExtras<P> = Omit<
297
  InstanceType<P & (new (...args: any[]) => any)>,
298
  keyof ValueObjectInstance<string, z.ZodTypeAny, unknown>
299
>
300

301
type ExtendedInstance<
302
  P extends ValueObjectConstructor<string, any, any>,
303
  ID extends string,
304
  NewT extends z.ZodTypeAny,
305
  NewJS,
306
> = ParentExtras<P> & ValueObjectInstance<ID, NewT, NewJS>
307

308
/** Strips construct/call signatures from a type, leaving only named static members. */
309
type StaticsOf<T> = { [K in keyof T]: T[K] }
310

311
export type ExtendedValueObjectConstructor<
312
  P extends ValueObjectConstructor<string, any, any>,
313
  ID extends string,
314
  NewT extends z.ZodTypeAny,
315
  NewJS,
316
> = StaticsOf<ValueObjectConstructor<ID, NewT, NewJS>> & {
317
  new (props: z.output<NewT>): ExtendedInstance<P, ID, NewT, NewJS>
318
}
319

320
/**
321
 * Returned when an `extend` schema produces an output not assignable to the parent's.
322
 * Not constructable, so misuse fails to type-check at the `class X extends ...` site.
323
 */
324
export type SchemaTransformOutputMismatchError = {
325
  __valueObjectError: 'Schema transform output must be assignable to the parent schema output'
326
}
327

328
/**
329
 * Derives a new value object class from an existing one. The returned class
330
 * extends `parent` directly, so `instanceof` and inherited methods work, and
331
 * the new schema is layered on top of the parent's via `options.schema`.
332
 *
333
 * @example
334
 * class Email extends ValueObject.define({
335
 *   id: 'Email',
336
 *   schema: () => z.string().email(),
337
 * }) {
338
 *   get domain() { return this.props.split('@')[1] }
339
 * }
340
 *
341
 * class GoogleEmail extends ValueObject.extends(Email, {
342
 *   id: 'GoogleEmail',
343
 *   schema: (prev) => prev.refine((s) => s.endsWith('@google.com'), 'must be a google email'),
344
 * }) {
345
 *   get isWorkspace() { return this.props.endsWith('@workspace.google.com') }
346
 * }
347
 *
348
 * const ge = GoogleEmail.fromJSON('alice@google.com')
349
 * ge instanceof Email       // true — prototype chain preserved
350
 * ge.domain                 // 'google.com' — inherited from Email
351
 * ge.isWorkspace            // false — defined on GoogleEmail
352
 */
353
export function extend<
354
  P extends ValueObjectConstructor<string, any, any>,
355
  ID extends string,
356
  NewT extends z.ZodTypeAny,
357
  NewJS = z.output<NewT>,
358
>(
359
  parent: P,
360
  options: {
361
    id: ID
362
    schema: (prev: SchemaOf<P>) => NewT
363
    toJSON?: (value: z.output<NewT>) => NewJS
364
  },
365
): z.output<NewT> extends z.output<SchemaOf<P>>
366
  ? ExtendedValueObjectConstructor<P, ID, NewT, NewJS>
367
  : SchemaTransformOutputMismatchError {
368
  const { id } = options
17✔
369

370
  const getSchema = once(() =>
17✔
371
    options.schema((parent as any)[RAW_SCHEMA_ACCESSOR_KEY]),
14✔
372
  )
373

374
  const schemaFn = once(function (klass: any) {
17✔
375
    return instanceOrConstruct(klass, getSchema())
13✔
376
  })
377

378
  const schemaPrimitiveFn = once(function (klass: any) {
17✔
379
    return getSchema().transform((value: any) => new klass(value))
1✔
380
  })
381

382
  const Extended = class extends (parent as any) {
49✔
383
    static [ValueObjectIdSymbol] = id
384
    static get [RAW_SCHEMA_ACCESSOR_KEY]() {
385
      return getSchema()
5✔
386
    }
387
    [ValueObjectIdSymbol] = id
32✔
388

389
    static schema(this: any) {
390
      return schemaFn(this)
41✔
391
    }
392

393
    static schemaPrimitive(this: any) {
394
      return schemaPrimitiveFn(this)
1✔
395
    }
396

397
    static schemaRaw() {
NEW
398
      return getSchema()
×
399
    }
400

401
    static fromJSON(this: any, props: any) {
402
      return this.schema().parse(props)
38✔
403
    }
404
  }
405

406
  if (options.toJSON) {
17✔
407
    const customToJSON = options.toJSON
2✔
408
    Object.defineProperty(Extended.prototype, 'toJSON', {
2✔
409
      value: function toJSON(this: any) {
410
        return recursivelyToJSON(customToJSON(this.props))
2✔
411
      },
412
      writable: true,
413
      configurable: true,
414
    })
415
  }
416

417
  return Extended as unknown as z.output<NewT> extends z.output<SchemaOf<P>>
17✔
418
    ? ExtendedValueObjectConstructor<P, ID, NewT, NewJS>
419
    : SchemaTransformOutputMismatchError
420
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc