Skip to content

feat!: Entity.field — flags live on the fields, options shrink to computed and invariants - #55

Merged
btravers merged 8 commits into
mainfrom
worktree-feat-entity-field
Aug 9, 2026
Merged

feat!: Entity.field — flags live on the fields, options shrink to computed and invariants#55
btravers merged 8 commits into
mainfrom
worktree-feat-entity-field

Conversation

@btravers

@btravers btravers commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Field modifiers move onto the fields that carry them, and the options object shrinks to the two things that are genuinely not field-shaped:

class Organization extends Entity("Organization")(
  {
    id:        Entity.field(OrgId, { generated: true, immutable: true }),
    createdAt: Entity.field(Instant, { generated: true, immutable: true }),
    slug:      Entity.field(Slug, { immutable: true }),
    name:      DisplayName,
  },
  {
    computed:   { /* inline is measured-impossible: the map would be self-referential, d → never */ },
    invariants: [ /* spans the entity, not one field */ ],
  },
) {}

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 and make constructs through this (measured: anything standing in front of the class stops being a constructor). Fields widens to accept either shape; a SchemaOf unwrap 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 where S prints 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 bare T, because an OnlyNominal intersection at the inference site was measured to break zod's $ZodBranded alias preservation; the map-level check owns the nominal rejection instead (the error moves to the field-map key, pinned).

Compile-time guarantees, each pinned

mistake what happens
unbranded schema inside Entity.field(...) rejected at the field-map key, same named error as a bare field
misspelled flag ({ imutable: true }) TS2561 suggests the spelling
misspelled flag beside a correct one rejected via UnknownFlagIsRejected — excess-property checking alone let this through, leaving the field silently mutable; found in review, closed, both shapes pinned
widened non-literal flag ({ generated: someBoolean }) rejected via RejectWidenedBoolean — the type said not-generated while the runtime generated; found in the final review, closed type-level after measuring it against const inference
redeclaring an inherited field on extend, flagged or not FieldAlreadyDeclaredByTheRoot at compile time, plus a declaration-time defect naming the key and tag (Object.hasOwn, so a field legitimately named toString works — in walked the prototype chain)

Flags accumulate across extend by map union; relaxing remains inexpressible.

Breaking — four items, separately named in the changeset

  1. The generated/immutable options keys are gone. Migration is mechanical: each key in a list becomes a flag on its own field. The changeset carries the table.
  2. Arity changes: Entity.Static<Tag, S, A, B?> (was five), Entity.Abstract<Name, S, A> (was five), Entity.BaseInstance<S, A> (was three). Empty-case never arguments simply disappear.
  3. A variant may not redeclare an inherited field, flagged or not — this breaks flag-free redeclaring variants too. Migration: declare the field once, on the root.
  4. A variant can no longer flag a root-declared field. The old options accumulation allowed 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

  • Flag derivation drives createInput/updateInput/factories exactly as the option lists did; a flagged entity-class field still yields real instances (the make-through-this regression pin)
  • Every compile-time guarantee above pinned in *.test-d.ts, with the load-bearing proofs recorded (directives flip to unused when the guard is reverted)
  • Redeclaration defect pinned directly, through a behaviour-only intermediate root (discriminates the static-chain walk), and for the prototype-named-field false-positive
  • examples/billing-domain fully 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 throughout
  • The size table is in the conversion commit's body; the baseline was re-measured from a fresh worktree after a stale-premise catch, and a reviewer reproduced every number byte-for-byte
  • format --check · lint · typecheck · test (179 package + example suites) · knip · build — green in CI order, uncached

For the reader of field.ts

Two spellings in that file are measured pins, not style: the bare T parameter (alias preservation) and the flag-rejection intersection (UnknownFlagIsRejected + RejectWidenedBoolean). Both comments say so with the numbers.

🤖 Generated with Claude Code

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
Copilot AI lite review requested due to automatic review settings August 9, 2026 16:22

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 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 a FieldSpec) and derives generated/immutable key sets from field-level flags at runtime.
  • Updates type-level plumbing (Fields/Schemas split, SchemaOf unwrapping, internal key-union computation, and Entity.Static/Entity.Abstract/Entity.BaseInstance arity 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.

Comment thread packages/entity/src/field.test-d.ts
Comment thread packages/entity/src/field.ts
@btravers
btravers merged commit 9d50d9a into main Aug 9, 2026
13 checks passed
@btravers
btravers deleted the worktree-feat-entity-field branch August 9, 2026 17:01
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