feat!: invariants become a list of Entity.invariant rules - #25
Merged
Conversation
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>
There was a problem hiding this comment.
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.tsand exposeEntity.invariantplus theEntity.Invariant<D>helper type. - Update invariant evaluation in
Entityconstruction to collect messages from all failing rules. - Change
extendoption merging soinvariantsconcatenates 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, spreadingchildInvariantsassumes 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.
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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`, + ), + ], }, ) {}ensurereturning true means valid, so a rule reads as the assertion it makes.messagetakes 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>isInputOf<S> & ComputedOf<A>, andComputedOfis a conditional ([keyof A] extends [never]) that stays deferred whileAis generic.Aisn't resolved when the invariants array is checked, so typingdas the output degrades it to a bag ofunknownon every entity that also declarescomputed.Four shapes were measured against the real types before settling here:
d: OutputOf<S,A>computedis also declared —d.shoutispossibly undefined/ index-signatureinvariants: (rule) => [...])Entity.invariantnever appears at a call sited: InputOf<S>NoInfer, no signature changecomputednever hit this because itsDisInputOf<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.extendno longer lets an extension shed its parent's rulesinvariantsis 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: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:
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.dneeds 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.dist/index.d.mtsscanned for circular self-aliases (none) —Entity.Invariantfollows the*Srcconvention from feat!: collapse the public surface ontoEntity#24.consumer/index.tsnamesEntity.invariantandEntity.Invariantthrough the builtd.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