Skip to content

feat!: invariants become a list of Entity.invariant rules - #25

Merged
btravers merged 2 commits into
mainfrom
feat/entity-invariant
Aug 7, 2026
Merged

feat!: invariants become a list of Entity.invariant rules#25
btravers merged 2 commits into
mainfrom
feat/entity-invariant

Conversation

@btravers

@btravers btravers commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

A rule and its message are now one value, so several rules no longer need hand-rolled accumulation inside a single function.

 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`,
+      ),
+    ],
   },
 ) {}

ensure returning true means valid, so a rule reads as the assertion it makes. message takes the data when the text depends on it. Every failing rule reports, not just the first — unchanged.

A rule sees the declared fields only

It cannot read a computed field. This is both a design position and a hard constraint, and worth reading before reviewing the rest.

The position: 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, not something to re-check in an invariant.

The constraint: OutputOf<S, A> is InputOf<S> & ComputedOf<A>, and ComputedOf is a conditional ([keyof A] extends [never]) that stays deferred while A is generic. A isn't resolved when the invariants array is checked, so typing d as the output degrades it to a bag of unknown on every entity that also declares computed.

Four shapes were measured against the real types before settling here:

shape result
plain option, d: OutputOf<S,A> fails wherever computed is also declared — d.shout is possibly undefined / index-signature
third positional parameter works, but makes the builder a three-argument curried call
callback (invariants: (rule) => [...]) works, but Entity.invariant never appears at a call site
plain option, d: InputOf<S> works, and needs no variance trick, no NoInfer, no signature change

computed never hit this because its D is InputOf<S> — no conditional to defer.

Consequence for existing code: computed.spec.ts's "invariants see the computed fields" test is rewritten to constrain the sources instead. That capability is genuinely removed, deliberately.

extend no longer lets an extension shed its parent's rules

invariants is the one option that concatenates parent-then-child instead of child-wins. That's what the comment above the merge always claimed the design intended:

silently dropping the parent's immutable or invariants would leave the extension quietly laxer than what it extends

while the code did the opposite — pinned, until now, by a test asserting a child could relax its parent. That test inverts, and two more cover the new behaviour:

// a child adds to the parent's rules
Stricter.make({ name: "x".repeat(21), age: 30 }).isErr()  // true — parent's rule still applies
// and cannot clear them
class Loose extends Person.extend("Loose")({ age: Age }, { invariants: [] }) {}
Loose.make({ name: "x".repeat(21), age: 1 }).isErr()      // true

Anyone relying on { invariants: () => [] } to relax a parent has no replacement. That escape hatch is gone on purpose.

Verification

Full gate green: format --check, lint, typecheck (three passes), test (122 passed), knip, build.

  • Type-level assertions that d needs no annotation, that an undeclared field errors, that the message function is typed the same way, and that a computed field is not visible to a rule.
  • The emitted dist/index.d.mts scanned for circular self-aliases (none) — Entity.Invariant follows the *Src convention from feat!: collapse the public surface onto Entity #24.
  • consumer/index.ts names Entity.invariant and Entity.Invariant through the built d.mts, per the rule that every namespace member must be reachable from outside; consumer pass reports zero diagnostics.

Also

docs/superpowers/ is untracked and gitignored, per request. That removes the namespace design doc that landed with #24.

🤖 Generated with Claude Code

A rule and its message are now one value, so several rules no longer need
hand-rolled accumulation inside a single function. `ensure` returning true
means valid; `message` takes the data when the text depends on it.

A rule sees the **declared** fields, not the output -- it cannot read a computed
field. That is both a design position and a hard constraint. 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.
The constraint: `OutputOf<S, A>` carries `ComputedOf<A>`, a conditional that
stays deferred while `A` is generic, and `A` is not yet resolved when the
invariants array is checked -- so typing `d` as the output degrades it to a bag
of `unknown` on every entity that also declares `computed`. Measured against
four alternative shapes; `InputOf<S>` is the only one that keeps a plain option
key, keeps `Entity.invariant` at the call site, and needs no variance or
`NoInfer` workaround.

`extend` no longer lets an extension shed its parent's rules. `invariants` is
the one option that concatenates parent-then-child rather than child-wins,
which is what the comment above the merge always claimed the design intended
while the code did the opposite -- pinned, until now, by a test asserting a
child could relax its parent.

BREAKING CHANGE: `invariants` takes `readonly Entity.Invariant[]` instead of
`(d) => readonly string[]`. Build each rule with `Entity.invariant(ensure,
message)`. Rules can no longer read computed fields; express them over the
declared fields those values derive from. An extension declaring `invariants`
now adds to its parent's rather than replacing them, and `invariants: []` no
longer clears them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 7, 2026 12:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR changes the Entity builder’s invariants option from a single (d) => string[] callback into a list of Entity.invariant(ensure, message) rules, and updates the runtime, types, tests, and docs accordingly. It also changes extend so invariants are inherited and concatenated (parent then child) rather than being replaceable by the child.

Changes:

  • Introduce packages/entity/src/invariant.ts and expose Entity.invariant plus the Entity.Invariant<D> helper type.
  • Update invariant evaluation in Entity construction to collect messages from all failing rules.
  • Change extend option merging so invariants concatenates instead of child-wins, and update specs / type-level tests / consumer fixture / docs to match.

Reviewed changes

Copilot reviewed 17 out of 18 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
README.md Updates public docs example/type summary for the new invariants rule list.
packages/entity/src/types.ts Updates the EntityStatic/extend option typing to use Invariant<InputOf<...>>[] instead of a message-accumulating function.
packages/entity/src/schema.spec.ts Migrates schema integration tests to rule-list invariants, including defect propagation behavior.
packages/entity/src/nesting.spec.ts Updates nesting invariant example to rule-list form.
packages/entity/src/invariant.ts Adds the new Invariant<D> type and invariant() constructor helper.
packages/entity/src/extend.spec.ts Updates tests for invariants rule-list usage and new invariant inheritance/concatenation semantics.
packages/entity/src/entity.ts Implements rule-list invariant evaluation, adds Entity.invariant, and changes extend to concatenate invariants.
packages/entity/src/entity.test-d.ts Updates type-level assertions for contextual typing, and enforces that invariants only “see” declared fields at the type level.
packages/entity/src/entity.spec.ts Updates invariant usage in runtime tests to rule-list form.
packages/entity/src/crud.spec.ts Updates CRUD invariant example to rule-list form.
packages/entity/src/computed.spec.ts Rewrites the computed/invariant interaction test to constrain computed values via declared sources.
packages/entity/consumer/index.ts Ensures the built declarations expose Entity.invariant and Entity.Invariant to consumers.
docs/superpowers/specs/2026-08-07-entity-namespace-design.md Removes the tracked design doc file (with the directory now gitignored).
docs/reference.md Updates reference docs for the new invariants type/behavior and adds Entity.invariant documentation.
docs/how-to/model-an-aggregate.md Updates how-to guide invariant example to rule-list form.
CLAUDE.md Updates architecture docs to include the new invariant.ts module and rationale for its typing.
.gitignore Ignores docs/superpowers/ as local scratch.
.changeset/entity-invariant.md Adds a changeset documenting the breaking change and migration guidance.
Suppressed comments (1)

packages/entity/src/entity.ts:398

  • In extend, spreading childInvariants assumes it is iterable; passing the old function-form invariants (or any non-array) will currently throw a low-signal runtime error when the array spread runs. Adding the same runtime guard here makes the breaking change fail fast with a clear message.
        const childInvariants = (
          nextOptions as { readonly invariants?: readonly Invariant<unknown>[] } | undefined
        )?.invariants;
        const invariants = [...(parentOptions?.invariants ?? []), ...(childInvariants ?? [])];

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/entity/src/entity.ts
Comment thread docs/reference.md Outdated
Comment thread README.md Outdated
`Invariant` and `ComputedField` were spelled unqualified, but the names a
consumer can actually reference are `Entity.Invariant` and
`Entity.ComputedField`. Both are generic; a note under the table says why the
parameters are elided rather than cluttering the cells with them.

The README's Meaning column is prose for every other option, so `invariants`
matches its neighbours instead of carrying a pseudo-type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@btravers
btravers merged commit ccaa572 into main Aug 7, 2026
13 checks passed
@btravers
btravers deleted the feat/entity-invariant branch August 7, 2026 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants