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
41 changes: 41 additions & 0 deletions .changeset/entity-invariant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@btravstack/entity": minor
---

**Breaking.** `invariants` is now a list of rules built with `Entity.invariant`,
replacing the single function that returned messages.

```diff
class Organization extends Entity("Organization")(
{ name: DisplayName, note: Line },
{
- invariants: (d) => [
- ...(d.name.length <= 80 ? [] : ["name must be at most 80 characters"]),
- ...(d.note.length >= d.name.length ? [] : ["note must be at least as long"]),
- ],
+ invariants: [
+ Entity.invariant((d) => d.name.length <= 80, "name must be at most 80 characters"),
+ Entity.invariant(
+ (d) => d.note.length >= d.name.length,
+ (d) => `note must be at least ${d.name.length} characters`,
+ ),
+ ],
},
) {}
```

A rule and its message are now one value, so several rules no longer need
hand-rolled accumulation. `ensure` returning **true** means valid. `message`
takes the data when the text depends on it. Every failing rule reports, not just
the first — unchanged from before.

**A rule now sees the declared fields only.** It can no longer read a computed
field. Every computed value is a function of declared data, so any rule about
one is expressible over its sources, and a computed value that fails its own
schema is already a Defect rather than something to re-check in an invariant.

**`extend` no longer lets an extension shed its parent's rules.** `invariants`
is the one option that concatenates parent-then-child instead of the child
replacing the parent. An extension can add rules; it cannot remove them, which
is what the design always intended. Code relying on `{ invariants: () => [] }`
to relax a parent has no replacement — that escape hatch is gone deliberately.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ coverage/

# macOS
.DS_Store

# Local design scratch, not part of the published docs
docs/superpowers/
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ that are not derivable from there:

## Architecture

Eight source modules under `packages/entity/src`, split by what they own:
Nine source modules under `packages/entity/src`, split by what they own:

- **`entity.ts`** — the builder. `Entity(tag)(fields, options)` derives the
four `ZodObject`s (`input`, `output`, `createInput`, `updateInput`) from
Expand Down Expand Up @@ -72,6 +72,12 @@ Eight source modules under `packages/entity/src`, split by what they own:
(public as `Entity.computed`) and the `InvalidEntity` tagged error. Computed
fields are re-derived on every construction path, so they cannot drift from
their sources.
- **`invariant.ts`** — `invariant(ensure, message)`, public as
`Entity.invariant`. A rule's `d` is `InputOf<S>`, **not** `OutputOf<S, A>`,
and that is not a simplification: `OutputOf` carries the deferred
`ComputedOf<A>` conditional, `A` is unresolved while the invariants array is
checked, and typing `d` as the output degrades it to a bag of `unknown`
wherever an entity declares `computed` too. Measured — see the comment there.

The design rule the whole package turns on: **contracts compose the four plain
`ZodObject`s; domain code composes the class itself.** The class carries a
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@ class Organization extends Entity("Organization")(
(d) => d.name.toUpperCase() as z.infer<typeof Upper>,
),
},
invariants: (d) =>
d.name.length <= 80 ? [] : ["name must be at most 80 characters"],
invariants: [
Entity.invariant(
(d) => d.name.length <= 80,
"name must be at most 80 characters",
),
],
},
) {
get greeting(): string {
Expand Down Expand Up @@ -120,7 +124,7 @@ Organization.make({ ...row, name: "" }).match({
| `generated` | fields the domain supplies, never the caller |
| `immutable` | fields that never change after creation |
| `computed` | fields derived from the declared ones, re-derived on every construction |
| `invariants` | `(output) => string[]` — a non-empty result rejects |
| `invariants` | rules built with `Entity.invariant`; any failing rule rejects |

Also `Entity.union(discriminant, members)` for a union that is itself
entity-like, and `SomeEntity.extend(tag)(fields)` to build a new entity from an
Expand Down
10 changes: 6 additions & 4 deletions docs/how-to/model-an-aggregate.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@ order.watchers[0].equals(other); // its behaviour
class Order extends Entity("Order")(
{ id: OrderId, customer: Customer, note: Line },
{
invariants: (d) =>
d.note.length >= d.customer.name.length
? []
: ["note must be at least as long as the name"],
invariants: [
Entity.invariant(
(d) => d.note.length >= d.customer.name.length,
"note must be at least as long as the name",
),
],
},
) {}
```
Expand Down
60 changes: 52 additions & 8 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,21 @@ Four names are reserved, because an entity installs them on every instance:

### `options`

| Option | Type | Effect |
| ------------ | ------------------------------- | -------------------------------------------------------------------------------- |
| `generated` | `readonly (keyof fields)[]` | omitted from `createInput`; supplied by a factory's generators |
| `immutable` | `readonly (keyof output)[]` | omitted from `updateInput`; `update()` drops them even if smuggled in at runtime |
| `computed` | `{ [name]: ComputedField }` | derived fields; added to `output`, re-derived on every construction |
| `invariants` | `(output) => readonly string[]` | rules spanning two or more fields; a non-empty result rejects |
| Option | Type | Effect |
| ------------ | ---------------------------------- | -------------------------------------------------------------------------------- |
| `generated` | `readonly (keyof fields)[]` | omitted from `createInput`; supplied by a factory's generators |
| `immutable` | `readonly (keyof output)[]` | omitted from `updateInput`; `update()` drops them even if smuggled in at runtime |
| `computed` | `{ [name]: Entity.ComputedField }` | derived fields; added to `output`, re-derived on every construction |
| `invariants` | `readonly Entity.Invariant[]` | rules spanning two or more declared fields; any failing rule rejects |

`generated` and `immutable` are keyed off the field names, so a typo is a
compile error rather than a silently-inert entry.

`Entity.ComputedField<T, D>` and `Entity.Invariant<D>` are both generic; the
parameters are elided above because you never write them. `Entity.computed` and
`Entity.invariant` infer them from the surrounding declaration, which is what
makes `d` contextually typed with no annotation.

## Schema members

```ts
Expand Down Expand Up @@ -142,6 +147,40 @@ field — every derivation is a function of declared data only.
Output that fails its own schema is a `Defect`, named for the field
(`Person.computed.initials: …`).

## `Entity.invariant(ensure, message)`

One rule spanning the whole entity: the predicate, and what to say when it
fails.

```ts
invariants: [
Entity.invariant(
(d) => d.name.length <= 80,
"name must be at most 80 characters",
),
Entity.invariant(
(d) => d.endsAt > d.startsAt,
(d) => `endsAt must be after ${d.startsAt}`,
),
];
```

`ensure` returning **true** means valid — a rule reads as the assertion it
makes. `d` is contextually typed and needs no annotation. `message` takes the
data when the text depends on it.

Every failing rule in the list reports, not just the first, and none of them
carries a `path`: an invariant spans the entity, which is what separates it from
a field complaint.

`d` is the **declared** fields, not the output — a rule cannot read a computed
field. Every computed value is a function of declared data, so any rule about
one is expressible over its sources, and a computed value failing its own schema
is already a Defect rather than something to re-check here.

A predicate that throws is a Defect, not an `InvalidEntity`, on the same
reasoning as `computed`.

## `SomeEntity.extend(tag)(fields, options?)`

A **new** entity carrying the parent's fields plus more, under its own tag —
Expand All @@ -155,8 +194,13 @@ class PersonWithAge extends Person.extend("PersonWithAge")({ age: Age }) {
}
```

Options merge per key, child winning. `extend` rebuilds from the
**declaration**, so class-body members do not carry over — re-declare them.
Options merge per key, child winning — **except `invariants`**, which
concatenates parent-then-child. An extension can add rules; it cannot shed them,
so it is never quietly laxer than what it extends. Declaring `invariants: []` on
a child does not clear the parent's.

`extend` rebuilds from the **declaration**, so class-body members do not carry
over — re-declare them.

## `Entity.union(discriminant, members)`

Expand Down
Loading
Loading