diff --git a/CHANGELOG.md b/CHANGELOG.md index 158db42..7b6538c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and `result.map` stay distinct. `/promise/fake` is excluded from the root, for the same reason it is a separate subpath in the first place. Closes #11. +- **Breaking:** `value-object/PrimitiveValueObject` and + `domain/DomainObjectFactory` gain an `E extends Error = Error` type + parameter, so a factory can declare the concrete Failure subclass its + `.from` reports instead of the two being pinned to plain `Error`. This + was the gap between the library's own pitch — CONTEXT.md's Failure entry + says "a Failure keeps its concrete Error subclass, so a caller can branch + on it" — and a factory that could never express which subclass that was. + `definePrimitiveValueObject` takes a new optional `errorHandler` argument + to translate a thrown value into that subclass, mirroring `tryCatch`'s own + handler-shaped overload split: with no `errorHandler`, `.from` routes + through `tryCatch`'s handler-less overload and `E` is `Error`; with one, + it routes through the generic overload and `E` follows the handler. Name + `E` explicitly alongside `T` when passing an `errorHandler`, since + `Success`'s invariant `E` phantom (docs/adr/0001, 2026-08-04 amendment) + keeps TypeScript from reliably inferring `E` from `errorHandler`'s return + type alone — confirmed by trying inference-only first and watching it + default back to `Error`. `DomainObjectFactory`'s new `E` sits ahead of the + existing `TExtra` parameter, both defaulted, so a call site naming neither + is unaffected and one naming only `TExtra` today needs to start naming `E` + too (or switch to naming both by position). + +- **Breaking:** `definePrimitiveValueObject`'s type parameters are now + ordered `T, P, E`, matching `PrimitiveValueObject` exactly, + reversing the `P, T` order it took before. `P` also now defaults to + `string` at the function itself, not just on the type. `T` — the + branded type actually being defined — is what a caller wants to name; + with `T` first and both `P` and `E` defaulted, the common case is one + explicit type argument, `definePrimitiveValueObject(...)`. The + old order left that same single argument compiling silently as `P` + instead of `T`, leaving `T` as `unknown` — confirmed by trying it, + not assumed. A call site naming both type arguments today needs them + swapped: `definePrimitiveValueObject(...)` becomes + `definePrimitiveValueObject(...)`. + +- **Breaking:** `domain/DomainObjectDTO` renamed to `domain/DTOSource`. + The old name and shape disagreed with CONTEXT.md's own DTO entry: a DTO + is "the plain, untrusted data shape a domain object is constructed from + and serialised to", but `DomainObjectDTO = { readonly dto: TDTO }` + was a domain object _carrying_ its DTO, not the DTO itself. `DTOSource` + names what the type actually is — the other direction of a Factory's + seam, recovering the DTO a domain object's current values would + round-trip back through the Factory that built it — and CONTEXT.md + gains a matching "DTO Source" entry next to Factory. Same shape, same + signature; only the name changed. A consumer referencing + `DomainObjectDTO` needs to rename the import and any `implements`/ + `satisfies` clause to `DTOSource`. - **Breaking:** `result/map`, `result/andThen` and `result/tryCatch` now reject a callback whose return type has a thenable arm — a `Promise`, a diff --git a/CONTEXT.md b/CONTEXT.md index 47b3362..36af91a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -135,6 +135,17 @@ untrusted data into a domain object. _Avoid_: Parser, validator, builder, constructor (a constructor cannot report failure as a value). +### DTO Source + +The other direction of a Factory's seam: a domain object that exposes a +`dto`, recovering the DTO its current values would round-trip back +through the Factory that built it. A DTO Source is not itself a DTO — it +is the domain object's own capability to produce one — and it carries no +invariants of its own. + +_Avoid_: Serializable, DTO (a DTO Source is not the untrusted data; see +the DTO entry above), toJSON, export. + ### Intern To canonicalise a Value Object so that equal values are represented by diff --git a/README.md b/README.md index def39a0..205f5af 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Each module is a separate subpath export. | `/result` | `Result`, `Success`, `Failure`, `ThrownError`, `NotAResult`, `NotAPromise`, `result`, `success`, `failure`, `isSuccess`, `isFailure`, `tryCatch`, `assertSuccess`, `map`, `mapError`, `fallback`, `orElse`, `andThen`, `fromMaybe` | | `/brand` | `Brand`, `Branded` | | `/value-object` | `Primitive`, `PrimitiveValueObject`, `definePrimitiveValueObject` | -| `/domain` | `Entity`, `CompoundValueObject`, `DomainObjectDTO`, `DomainObjectFactory` | +| `/domain` | `Entity`, `CompoundValueObject`, `DTOSource`, `DomainObjectFactory` | | `/intern-registry` | `InternRegistry` | | `/fn` | `Fn`, `Mapper`, `CurryableMapper`, `compose`, `pipe`, `curry`, `identity`, `constant` | | `/promise` | `AbortablePromise`, `AbortContext`, `RejectionError`, `resultify`, `fail`, `recoverWith`, `State` with its constructors and guards, `settledResult`, `stateOf` | diff --git a/src/domain/index.test-d.ts b/src/domain/index.test-d.ts new file mode 100644 index 0000000..bfc2aef --- /dev/null +++ b/src/domain/index.test-d.ts @@ -0,0 +1,32 @@ +import { describe, expectTypeOf, it } from 'vitest' +import type { DomainObjectFactory } from './index.js' +import type { Result } from '../result/index.js' + +type UserDTO = { id: string; name: string } +type User = { readonly id: string; readonly name: string } + +class InvalidUser extends Error { + readonly code = 'invalid-user' as const +} + +describe('DomainObjectFactory', () => { + it('defaults the Failure type to Error', () => { + expectTypeOf['from']>().toEqualTypeOf< + (dto: UserDTO) => Result + >() + }) + + it('narrows the Failure type to a concrete Error subclass, ahead of TExtra', () => { + expectTypeOf< + DomainObjectFactory['from'] + >().toEqualTypeOf<(dto: UserDTO) => Result>() + }) + + it('keeps TExtra usable with its own default left in place', () => { + expectTypeOf< + DomainObjectFactory['from'] + >().toEqualTypeOf< + (dto: UserDTO, actorId: string) => Result + >() + }) +}) diff --git a/src/domain/index.test.ts b/src/domain/index.test.ts index b141e73..2a57dac 100644 --- a/src/domain/index.test.ts +++ b/src/domain/index.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' import type { CompoundValueObject, - DomainObjectDTO, DomainObjectFactory, + DTOSource, Entity, } from './index.js' import { @@ -44,14 +44,39 @@ describe('DomainObjectFactory', () => { }) }) +class EmptyName extends Error { + readonly code = 'empty-name' as const +} + +const StrictUserFactory: DomainObjectFactory = { + from(dto) { + // success(...) is spelled out explicitly: Success's `E` + // phantom is invariant (docs/adr/0001, 2026-08-04 amendment), so the + // plain `success(...)` call below would default E to Error and fail to + // satisfy this factory's narrower Result. + return dto.name.length > 0 + ? success({ id: dto.id, name: dto.name }) + : failure(new EmptyName('name must not be empty')) + }, +} + +describe('DomainObjectFactory with a concrete Failure subclass', () => { + it('lets a caller branch on the specific Error subclass a Failure carries', () => { + const outcome = StrictUserFactory.from({ id: 'u1', name: '' }) + expect(isFailure(outcome)).toBe(true) + expect(outcome).toBeInstanceOf(EmptyName) + if (isFailure(outcome)) { + expect(outcome.code).toBe('empty-name') + } + }) +}) + type PointDTO = { x: number; y: number } type PointKey = string const pointRegistry = new InternRegistry() -class Point - implements CompoundValueObject, DomainObjectDTO -{ +class Point implements CompoundValueObject, DTOSource { readonly key: PointKey readonly x: number readonly y: number @@ -78,7 +103,7 @@ class Point } } -describe('DomainObjectDTO round-trip', () => { +describe('DTOSource round-trip', () => { it('round-trips: from(dto).dto equals the original dto', () => { const point = assertSuccess(Point.from({ x: 1, y: 2 })) expect(point.dto).toEqual({ x: 1, y: 2 }) @@ -105,11 +130,11 @@ describe('type-level', () => { Equal, { readonly key: string }> > - type _DTOHasDto = Expect< - Equal, { readonly dto: UserDTO }> + type _DTOSourceHasDto = Expect< + Equal, { readonly dto: UserDTO }> > - const _typeTests: [_EntityHasId, _CompoundHasKey, _DTOHasDto] = [ + const _typeTests: [_EntityHasId, _CompoundHasKey, _DTOSourceHasDto] = [ true, true, true, diff --git a/src/domain/index.ts b/src/domain/index.ts index d0e56d4..bfadc8d 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -14,22 +14,35 @@ export type Entity = { readonly id: TId } export type CompoundValueObject = { readonly key: TKey } /** - * The plain, untrusted data shape a domain object is constructed from and - * serialised to. A DTO carries no invariants; a {@link DomainObjectFactory} - * is what validates one into a domain object. + * The seam that recovers a domain object's DTO: a readonly `dto` built + * from the object's own current values, the mirror of what a {@link + * DomainObjectFactory}'s `from` validated it out of in the first place. + * Not the DTO itself — see CONTEXT.md's DTO entry — this is the domain + * object's side of the round trip, which is why it is named for the + * recovery it performs rather than reusing "DTO" for a shape that carries + * no invariants of its own. */ -export type DomainObjectDTO = { readonly dto: TDTO } +export type DTOSource = { readonly dto: TDTO } /** * The construction seam of a domain object: takes a DTO (plus any `extra` * arguments the construction needs) and returns either the domain object or * a `Failure` explaining why the DTO was not admissible. The only * sanctioned way to turn untrusted data into a domain object. + * + * `E` defaults to `Error`, but a factory can narrow it to a concrete + * subclass — e.g. `DomainObjectFactory` — so a + * caller can branch on the specific reason a DTO was rejected, per + * CONTEXT.md's Failure entry. It sits ahead of `TExtra` because, like + * `Result`, it describes the shape of `from`'s return value rather + * than an input; `TExtra` describes extra input and keeps its own default + * so a factory with no extra arguments never has to name either. */ export type DomainObjectFactory< TDomain, TDTO, + E extends Error = Error, TExtra extends unknown[] = [], > = { - from(dto: TDTO, ...extra: TExtra): Result + from(dto: TDTO, ...extra: TExtra): Result } diff --git a/src/value-object/index.test-d.ts b/src/value-object/index.test-d.ts new file mode 100644 index 0000000..11d4f90 --- /dev/null +++ b/src/value-object/index.test-d.ts @@ -0,0 +1,49 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { definePrimitiveValueObject } from './index.js' +import type { Branded } from '../brand/index.js' +import type { Result } from '../result/index.js' + +type Email = Branded + +class InvalidEmail extends Error { + readonly code = 'invalid-email' as const +} + +describe('definePrimitiveValueObject', () => { + it("defaults .from's Failure type to Error when no errorHandler is given", () => { + const Email = definePrimitiveValueObject((value) => { + if (!value.includes('@')) throw new Error(`invalid email: "${value}"`) + return value as Email + }) + + expectTypeOf(Email.from('a@b.com')).toEqualTypeOf>() + }) + + it("narrows .from's Failure type to errorHandler's concrete Error subclass, given explicitly", () => { + // E is not reliably inferred from errorHandler's return type alone — the + // same Result invariance documented in ADR-0001's 2026-08-04 amendment + // (Success's `E` phantom makes Result invariant in E) applies here too, + // confirmed by trying inference-only first and watching it default back + // to Error. Naming E explicitly alongside T is the fix, same as + // constructing a Result directly at a narrowly-typed call site. + const Email = definePrimitiveValueObject( + (value) => { + if (!value.includes('@')) throw new Error(`invalid email: "${value}"`) + return value as Email + }, + (): Result => new InvalidEmail('invalid email'), + ) + + expectTypeOf(Email.from('a@b.com')).toEqualTypeOf< + Result + >() + }) + + it("names only T explicitly, letting P default to string — the point of matching PrimitiveValueObject's T, P order", () => { + const Email = definePrimitiveValueObject((value) => value as Email) + + expectTypeOf(Email.from).toEqualTypeOf< + (value: string) => Result + >() + }) +}) diff --git a/src/value-object/index.test.ts b/src/value-object/index.test.ts index 83bff86..cf32054 100644 --- a/src/value-object/index.test.ts +++ b/src/value-object/index.test.ts @@ -5,7 +5,11 @@ import { isFailure, isSuccess } from '../result/index.js' type Email = Branded -const Email = definePrimitiveValueObject((value) => { +class InvalidEmail extends Error { + readonly code = 'invalid-email' as const +} + +const Email = definePrimitiveValueObject((value) => { if (!value.includes('@')) { throw new Error(`invalid email: "${value}"`) } @@ -34,11 +38,25 @@ describe('definePrimitiveValueObject', () => { it('does not mutate the constructor passed in, and returns a new factory', () => { const construct = (value: string) => value as Branded - const factory = definePrimitiveValueObject< - string, - Branded - >(construct) + const factory = + definePrimitiveValueObject>(construct) expect(factory).not.toBe(construct) expect('from' in construct).toBe(false) }) + + it('.from routes a thrown value through the given errorHandler', () => { + const StrictEmail = definePrimitiveValueObject( + (value) => { + if (!value.includes('@')) { + throw new Error(`invalid email: "${value}"`) + } + return value as Email + }, + () => new InvalidEmail('invalid email'), + ) + + const outcome = StrictEmail.from('not-an-email') + expect(isFailure(outcome)).toBe(true) + expect(outcome).toBeInstanceOf(InvalidEmail) + }) }) diff --git a/src/value-object/index.ts b/src/value-object/index.ts index b3329bc..4dfab15 100644 --- a/src/value-object/index.ts +++ b/src/value-object/index.ts @@ -1,6 +1,7 @@ import { tryCatch } from '../result/index.js' import type { Result } from '../result/index.js' import type { Branded } from '../brand/index.js' +import type { Mapper } from '../fn/index.js' /** The primitive types a {@link PrimitiveValueObject} can brand. */ export type Primitive = string | number | boolean | null @@ -8,13 +9,18 @@ export type Primitive = string | number | boolean | null /** * The shape produced by {@link definePrimitiveValueObject}: callable * directly to construct `T` (throwing on invalid input), and carrying a - * `.from` that returns a `Result` instead of throwing. + * `.from` that returns a `Result` instead of throwing. `E` defaults to + * `Error` — the type `tryCatch`'s handler-less overload produces — but a + * factory built with its own `errorHandler` narrows `.from` to that + * handler's concrete Failure type, so a caller can branch on it (see + * CONTEXT.md's Failure entry). */ export type PrimitiveValueObject< T extends Branded, P extends Primitive = string, + E extends Error = Error, > = ((value: P) => T) & { - from: (value: P) => Result + from: (value: P) => Result } /** @@ -22,11 +28,60 @@ export type PrimitiveValueObject< * constructor. `construct` should throw to reject invalid input; the * returned factory is callable directly for the throwing form, and exposes * `.from` — built from `construct` via `tryCatch` — for the `Result` form. + * + * Type parameters are ordered `T, P, E`, matching {@link + * PrimitiveValueObject} exactly, rather than the `P, T` this function used + * to take. `T` — the branded type being defined — is what a caller + * actually wants to name; `P` almost always defaults to `string` and `E` + * to `Error`, so with both defaulted and `T` first, the common case is a + * single explicit type argument, `definePrimitiveValueObject(...)`, + * with nothing to get out of order. The reverse would leave `T` unable to + * default (a real branded type never should) while accepting a lone + * argument silently as `P` instead — confirmed by trying it: with the old + * `P, T` order, `definePrimitiveValueObject(...)` compiles by + * assigning `Email` to `P`, which is Email's own underlying primitive + * structurally but not the intent, and `T` is left as `unknown`. + * + * `.from`'s Failure type is `Error` by default, matching `tryCatch`'s own + * handler-less overload. Pass `errorHandler` to translate a thrown value + * into a concrete `Error` subclass instead; it is forwarded to `tryCatch` + * verbatim, so `.from` routes through `tryCatch`'s generic-in-`E` overload + * instead. Name `E` explicitly alongside `T` when doing so: `Result`'s + * `Success` carries an invariant `E` phantom (docs/adr/0001, 2026-08-04 + * amendment), which keeps TypeScript from reliably inferring `E` from + * `errorHandler`'s return type alone — confirmed by trying inference-only + * first and watching it default back to `Error`. This is the same + * annotation the ADR already asks for when constructing a `Result` directly + * at a call site typed to a narrower error class. + * + * Two overloads, split for the same reason `tryCatch` itself is: naming `E` + * without supplying an `errorHandler` would otherwise let a thrown value + * other than `E` reach `.from` uncaught by the type system. Without a + * handler, `E` cannot be named at all — `.from`'s Failure type is `Error`. */ export function definePrimitiveValueObject< - P extends Primitive, T extends Branded, ->(construct: (value: P) => T): PrimitiveValueObject { + P extends Primitive = string, +>(construct: (value: P) => T): PrimitiveValueObject +export function definePrimitiveValueObject< + T extends Branded, + P extends Primitive = string, + E extends Error = Error, +>( + construct: (value: P) => T, + errorHandler: Mapper>, +): PrimitiveValueObject +export function definePrimitiveValueObject< + T extends Branded, + P extends Primitive = string, + E extends Error = Error, +>( + construct: (value: P) => T, + errorHandler?: Mapper>, +): PrimitiveValueObject { const factory = (value: P) => construct(value) - return Object.assign(factory, { from: tryCatch(construct) }) + const from = errorHandler + ? tryCatch(construct, errorHandler) + : (tryCatch(construct) as (value: P) => Result) + return Object.assign(factory, { from }) }