feat!: Entity.field — flags live on the fields, options shrink to computed and invariants - #55
Merged
Merged
Conversation
Converts organization.ts, root.ts and index.ts's two variants from the
generated/immutable options-list spelling to Entity.field(schema, { generated,
immutable }) inline flags (Tasks 1-2). vocabulary.ts and index.spec.ts are
untouched. emit-guards.ts drops Entity.BaseInstance/Static/Abstract to their
reduced arity (2/4/3 type arguments) and adds a named Entity.FieldSpec guard.
Structural proof: grep for GeneratedKeys</ImmutableKeys< across the emitted
.d.ts set returns zero hits - the arity reduction keeps the flag-derived key
unions out of type-argument position, as designed.
Size table (wc -c on examples/billing-domain/node_modules/.emit-check/*.d.ts;
baseline measured directly at origin/main 447e8d6, not lifted unadjusted from
the spike report - see task-3-report.md for why):
| File | Baseline | Now | Delta | Delta% |
|--------------------|---------:|-------:|-------:|-------:|
| index.d.ts | 10,145 | 12,189 | +2,044 | +20.1% |
| organization.d.ts | 1,234 | 1,664 | +430 | +34.8% |
| root.d.ts | 3,023 | 3,232 | +209 | +6.9% |
| emit-guards.d.ts | 4,723 | 4,808 | +85 | +1.8% |
| vocabulary.d.ts | 4,593 | 4,593 | 0 | 0.0% |
| index.spec.d.ts | 11 | 11 | 0 | 0.0% |
| total | 23,729 | 26,497 | +2,768 | +11.7% |
For scale: the earlier spike's naive inline spelling (unresolved
GeneratedKeys/ImmutableKeys repeating whole field maps) grew the same fixture
+57.8% total, +104% on index.d.ts alone. This conversion's +11.7% is 4.9x
smaller, with the leak-detection grep clean.
Full report, per-file breakdown and gate output:
.superpowers/sdd/2026-08-09-entity-field/task-3-report.md
Review of the billing-domain conversion found field()'s parameter,
schema: T & OnlyNominal<{ value: T }>["value"], intersecting T with a
type-level check at an inference site. That intersection made the emitter
give up on writing a nameable branded alias (z.core.$ZodBranded<...>) by
reference for every flagged field, expanding it structurally instead
(ZodString & { _zod: { output: string & $brand<"Slug"> } }) - ~42 B per
appearance, 32% of the conversion's emitted-size delta.
The map-level OnlyNominal<S> (shape.ts, applied at every Entity(...)/
Entity.abstract(...)/.extend(...) call site) already unwraps FieldSpec via
SchemaOf before judging nominality, so field()'s own check was redundant -
verified by respelling the parameter to bare T and confirming an unbranded
schema wrapped in Entity.field(...) is still rejected, just at the field-map
key instead of at the field() call. field.test-d.ts's rejection pin moved to
match.
Re-measured examples/billing-domain/node_modules/.emit-check/*.d.ts after
rebuilding: total emitted size drops from 26,497 B to 25,623 B (-874 B,
matching the reviewer's predicted swing to the byte). Delta vs the true
baseline (23,729 B, measured at origin/main 447e8d6) is now +1,894 B
(+8.0%), down from +2,768 B (+11.7%):
| File | Baseline | Post-fix | Delta | Delta% |
|--------------------|---------:|---------:|-------:|-------:|
| index.d.ts | 10,145 | 11,499 | +1,354 | +13.3% |
| organization.d.ts | 1,234 | 1,526 | +292 | +23.7% |
| root.d.ts | 3,023 | 3,186 | +163 | +5.4% |
| emit-guards.d.ts | 4,723 | 4,808 | +85 | +1.8% |
| vocabulary.d.ts | 4,593 | 4,593 | 0 | 0.0% |
| index.spec.d.ts | 11 | 11 | 0 | 0.0% |
| total | 23,729 | 25,623 | +1,894 | +8.0% |
Four-step billing-domain typecheck and the whole-repo gate (format, lint,
typecheck, test x205, knip, build) re-run clean. GeneratedKeys</ImmutableKeys<
grep proof still zero hits.
Full writeup: .superpowers/sdd/2026-08-09-entity-field/task-3-report.md
Convert every fenced declaration off the `generated`/`immutable` option keys onto `Entity.field(schema, flags)`, and state the two rules that came with it: a variant may not redeclare an inherited field, and therefore may not flag a root-declared one either. - `reference/declaration.md` gains an `Entity.field` section with the flag table, the misspelled-flag rejection and the map-level nominal check; the options table shrinks to `computed`/`invariants`; the extend merge table loses the two list rows and gains the flags-ride-their-fields row plus the redeclaration forbid with its defect message. - `reference/types.md` records the three arity reductions and why a key union in argument position cannot be de-aliased, and adds `FieldSpec` to the declaration-emit names (nine → ten). - `typedoc.json`: `NoRedeclaredKeys` / `UnknownFlagIsRejected` added, `ComputedOf` / `Entry` dropped — the docs build is warning-free again. - `CLAUDE.md`: `field.ts` in the module list, `base.ts`'s merge rewritten, the de-aliasing dead end added to the measured-comments list.
- base.ts: use Object.hasOwn instead of `in` for the redeclaration clash check, so a variant field named `constructor`/`toString` no longer trips a false "already declared" defect via the prototype chain - field.ts: reject a widened (non-literal) boolean flags value at the type level, closing a type/runtime divergence on generated/immutable - base.spec.ts, field.test-d.ts: pin both of the above, plus the deferred test that redeclaration through a behaviour-only intermediate root defects - CLAUDE.md: re-indent a misaligned continuation line
There was a problem hiding this comment.
Pull request overview
This PR refactors the entity declaration API by moving generated/immutable modifiers from Entity(...)(fields, options) onto the fields themselves via Entity.field(schema, flags), shrinking the options object to only computed and invariants. This updates the core builder, type-level derivations (including arity reductions to protect .d.ts emit size), tests/fixtures, and documentation to match the new declaration shape.
Changes:
- Introduces
Entity.field(schema, flags)(backed by aFieldSpec) and derives generated/immutable key sets from field-level flags at runtime. - Updates type-level plumbing (
Fields/Schemassplit,SchemaOfunwrapping, internal key-union computation, andEntity.Static/Entity.Abstract/Entity.BaseInstancearity changes). - Migrates examples, tests (
*.spec.ts,*.test-d.ts), docs, and the changeset to the new API and breaking-change story.
Reviewed changes
Copilot reviewed 36 out of 36 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Updates top-level README examples/docs to field-level flags and reduced options. |
| packages/entity/src/types.ts | Reworks core helper types around FieldSpec, SchemaOf, and internal key-union derivation. |
| packages/entity/src/shape.ts | Unwraps flagged fields via schemaOf() before building the zod object shape. |
| packages/entity/src/index.ts | Re-exports FieldSpec for consumer declaration-emit stability. |
| packages/entity/src/field.ts | Adds Flags/FieldSpec and implements field() plus runtime helpers (isFieldSpec, schemaOf). |
| packages/entity/src/field.test-d.ts | Adds type-level guards for flags extraction, typo/widened-boolean rejection, and removed options. |
| packages/entity/src/field.spec.ts | Adds runtime tests proving flags drive createInput/updateInput, factories, and nested entity fields. |
| packages/entity/src/entity.ts | Derives generated/immutable keys from field specs; updates schema derivation and factory/update typings. |
| packages/entity/src/entity.test-d.ts | Migrates type-level entity tests from options-lists to field flags. |
| packages/entity/src/crud.spec.ts | Migrates runtime CRUD tests to field-level flags. |
| packages/entity/src/contract.spec.ts | Migrates contract tests to field-level flags. |
| packages/entity/src/computed.spec.ts | Migrates computed-field tests to field-level flags. |
| packages/entity/src/base.ts | Removes generated/immutable option merging; adds runtime redeclaration defect; retains computed/invariants merging. |
| packages/entity/src/base.test-d.ts | Updates type-level root/extend tests for flag inheritance and redeclaration rejection. |
| packages/entity/src/base.spec.ts | Updates runtime root/extend behavior tests for flags and redeclaration defects. |
| packages/entity/README.md | Updates package-level README examples and tables to reflect field-level flags. |
| examples/billing-domain/src/root.ts | Migrates example root entity to field flags; updates explanatory comment about arity. |
| examples/billing-domain/src/organization.ts | Migrates example entity to field flags; updates explanatory block comment. |
| examples/billing-domain/src/index.ts | Migrates example variants to field flags and removes generated/immutable option lists. |
| examples/billing-domain/src/emit-guards.ts | Updates emit guard types for new arities and adds Entity.FieldSpec coverage. |
| docs/typedoc.json | Updates excluded symbol list to reflect new internal/exported types related to field flags. |
| docs/tutorial/getting-started.md | Updates tutorial examples/explanations to use Entity.field and field-level flags. |
| docs/reference/types.md | Updates helper-type docs, declaration-emit list, and arity-change explanations for the new API. |
| docs/reference/schemas.md | Updates schema-member descriptions to reference field flags. |
| docs/reference/entry-points.md | Updates entry-point docs to describe generated/immutable as field flags. |
| docs/reference/declaration.md | Updates declaration reference: removes generated/immutable options, adds Entity.field section, and describes redeclaration defect. |
| docs/index.md | Updates homepage copy and code sample to reflect field flags and reduced options. |
| docs/how-to/test-domain-logic.md | Updates guidance wording from options to field flags. |
| docs/how-to/model-an-aggregate.md | Notes that nested entities can be flagged (e.g., immutable). |
| docs/how-to/http-contract.md | Updates HTTP contract guidance to reference field flags. |
| docs/how-to/evolve-an-entity.md | Updates evolution guidance for flag inheritance and redeclaration prohibition. |
| docs/examples/index.md | Updates examples overview wording to field flags. |
| docs/examples/billing-domain.md | Migrates billing-domain docs/examples to field flags and updated root/variant semantics. |
| docs/examples/billing-api.md | Updates billing API doc wording to field flags. |
| CLAUDE.md | Updates repository guidance to include field.ts module and new flag/arity/rules narrative. |
| .changeset/entity-field.md | Adds a changeset documenting the new API, migrations, arity changes, and breaking items. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…d typo diagnostic
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.
Field modifiers move onto the fields that carry them, and the options object shrinks to the two things that are genuinely not field-shaped:
The design, and the measurement that shaped it
Entity.field(schema, flags)returns a spec object the builder unwraps — never a schema impersonation, because entity classes are legal fields andmakeconstructs throughthis(measured: anything standing in front of the class stops being a constructor).Fieldswidens to accept either shape; aSchemaOfunwrap threads through every derived type.The emit problem was solved structurally, not fought. A spike had measured the naive shape at +57.8% consumer emitted size:
GeneratedKeys<S>in type-argument position re-carries the entire field map, and de-aliasing it is measured-impossible three ways — the printer's union-origin tracking reconstitutes the alias on both compilers. The fix is arity reduction:EntityStatic<Tag, S, A, B>(was six parameters),AbstractEntity<Name, S, A>,BaseInstance<S, A>, with the key unions computed inside the bodies whereSprints by name. The field map appears exactly once per emitted declaration, by construction.Measured on the consumer fixture: baseline 23,729 B → 25,623 B = +1,894 B (+8.0%), 10 flagged fields, 21 appearances, ~90 B per appearance. Zero
GeneratedKeys</ImmutableKeys<hits in any emitted.d.ts— the structural proof, checked by grep as an acceptance artifact. A mid-branch review reclaimed 874 B of that:field()'s parameter is bareT, because anOnlyNominalintersection at the inference site was measured to break zod's$ZodBrandedalias preservation; the map-level check owns the nominal rejection instead (the error moves to the field-map key, pinned).Compile-time guarantees, each pinned
Entity.field(...){ imutable: true })TS2561suggests the spellingUnknownFlagIsRejected— excess-property checking alone let this through, leaving the field silently mutable; found in review, closed, both shapes pinned{ generated: someBoolean })RejectWidenedBoolean— the type said not-generated while the runtime generated; found in the final review, closed type-level after measuring it againstconstinferenceextend, flagged or notFieldAlreadyDeclaredByTheRootat compile time, plus a declaration-time defect naming the key and tag (Object.hasOwn, so a field legitimately namedtoStringworks —inwalked the prototype chain)Flags accumulate across
extendby map union; relaxing remains inexpressible.Breaking — four items, separately named in the changeset
generated/immutableoptions keys are gone. Migration is mechanical: each key in a list becomes a flag on its own field. The changeset carries the table.Entity.Static<Tag, S, A, B?>(was five),Entity.Abstract<Name, S, A>(was five),Entity.BaseInstance<S, A>(was three). Empty-caseneverarguments simply disappear.immutable: ["rootKey"]on a variant; a field's flags now live only at its declaration site. This fell out of composing the two design rulings and is named rather than buried.minor, per 0.x.Test plan
createInput/updateInput/factories exactly as the option lists did; a flagged entity-class field still yields real instances (themake-through-thisregression pin)*.test-d.ts, with the load-bearing proofs recorded (directives flip to unused when the guard is reverted)examples/billing-domainfully converted; its spec untouched and green; the four-step consumer gate (emit on 7.0.2 and 5.9.3, then type-check of the emitted output on 5.9.3) green throughoutformat --check·lint·typecheck·test(179 package + example suites) ·knip·build— green in CI order, uncachedFor the reader of
field.tsTwo spellings in that file are measured pins, not style: the bare
Tparameter (alias preservation) and the flag-rejection intersection (UnknownFlagIsRejected+RejectWidenedBoolean). Both comments say so with the numbers.🤖 Generated with Claude Code