Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T, P, E>` 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<Email>(...)`. 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<P, T>(...)` becomes
`definePrimitiveValueObject<T, P>(...)`.

- **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<TDTO> = { 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
Expand Down
11 changes: 11 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
32 changes: 32 additions & 0 deletions src/domain/index.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<DomainObjectFactory<User, UserDTO>['from']>().toEqualTypeOf<
(dto: UserDTO) => Result<User, Error>
>()
})

it('narrows the Failure type to a concrete Error subclass, ahead of TExtra', () => {
expectTypeOf<
DomainObjectFactory<User, UserDTO, InvalidUser>['from']
>().toEqualTypeOf<(dto: UserDTO) => Result<User, InvalidUser>>()
})

it('keeps TExtra usable with its own default left in place', () => {
expectTypeOf<
DomainObjectFactory<User, UserDTO, InvalidUser, [actorId: string]>['from']
>().toEqualTypeOf<
(dto: UserDTO, actorId: string) => Result<User, InvalidUser>
>()
})
})
41 changes: 33 additions & 8 deletions src/domain/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import type {
CompoundValueObject,
DomainObjectDTO,
DomainObjectFactory,
DTOSource,
Entity,
} from './index.js'
import {
Expand Down Expand Up @@ -44,14 +44,39 @@ describe('DomainObjectFactory', () => {
})
})

class EmptyName extends Error {
readonly code = 'empty-name' as const
}

const StrictUserFactory: DomainObjectFactory<User, UserDTO, EmptyName> = {
from(dto) {
// success<User, EmptyName>(...) 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<User, EmptyName>.
return dto.name.length > 0
? success<User, EmptyName>({ id: dto.id, name: dto.name })
: failure<EmptyName, User>(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<PointKey, Point>()

class Point
implements CompoundValueObject<PointKey>, DomainObjectDTO<PointDTO>
{
class Point implements CompoundValueObject<PointKey>, DTOSource<PointDTO> {
readonly key: PointKey
readonly x: number
readonly y: number
Expand All @@ -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 })
Expand All @@ -105,11 +130,11 @@ describe('type-level', () => {
Equal<CompoundValueObject<string>, { readonly key: string }>
>

type _DTOHasDto = Expect<
Equal<DomainObjectDTO<UserDTO>, { readonly dto: UserDTO }>
type _DTOSourceHasDto = Expect<
Equal<DTOSource<UserDTO>, { readonly dto: UserDTO }>
>

const _typeTests: [_EntityHasId, _CompoundHasKey, _DTOHasDto] = [
const _typeTests: [_EntityHasId, _CompoundHasKey, _DTOSourceHasDto] = [
true,
true,
true,
Expand Down
23 changes: 18 additions & 5 deletions src/domain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,35 @@ export type Entity<TId> = { readonly id: TId }
export type CompoundValueObject<TKey> = { 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<TDTO> = { readonly dto: TDTO }
export type DTOSource<TDTO> = { 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<User, UserDTO, InvalidUser>` — 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<T, E>`, 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<TDomain, Error>
from(dto: TDTO, ...extra: TExtra): Result<TDomain, E>
}
49 changes: 49 additions & 0 deletions src/value-object/index.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<string, 'Email'>

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<Email>((value) => {
if (!value.includes('@')) throw new Error(`invalid email: "${value}"`)
return value as Email
})

expectTypeOf(Email.from('a@b.com')).toEqualTypeOf<Result<Email, Error>>()
})

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<Email, string, InvalidEmail>(
(value) => {
if (!value.includes('@')) throw new Error(`invalid email: "${value}"`)
return value as Email
},
(): Result<Email, InvalidEmail> => new InvalidEmail('invalid email'),
)

expectTypeOf(Email.from('a@b.com')).toEqualTypeOf<
Result<Email, InvalidEmail>
>()
})

it("names only T explicitly, letting P default to string — the point of matching PrimitiveValueObject's T, P order", () => {
const Email = definePrimitiveValueObject<Email>((value) => value as Email)

expectTypeOf(Email.from).toEqualTypeOf<
(value: string) => Result<Email, Error>
>()
})
})
28 changes: 23 additions & 5 deletions src/value-object/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import { isFailure, isSuccess } from '../result/index.js'

type Email = Branded<string, 'Email'>

const Email = definePrimitiveValueObject<string, Email>((value) => {
class InvalidEmail extends Error {
readonly code = 'invalid-email' as const
}

const Email = definePrimitiveValueObject<Email>((value) => {
if (!value.includes('@')) {
throw new Error(`invalid email: "${value}"`)
}
Expand Down Expand Up @@ -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<string, 'Email'>
const factory = definePrimitiveValueObject<
string,
Branded<string, 'Email'>
>(construct)
const factory =
definePrimitiveValueObject<Branded<string, 'Email'>>(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<Email, string, InvalidEmail>(
(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)
})
})
Loading
Loading