Skip to content

feat: type computed and generator returns as the schema's input - #50

Merged
btravers merged 6 commits into
mainfrom
worktree-feat-castless-producers
Aug 9, 2026
Merged

feat: type computed and generator returns as the schema's input#50
btravers merged 6 commits into
mainfrom
worktree-feat-castless-producers

Conversation

@btravers

@btravers btravers commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Top finding of the post-0.4.0 DX audit: producer callbacks were typed as the branded output where the runtime immediately parses them as input, forcing a ceremonial cast at every computed field and every factory generator.

The problem

// every computed field ended like this:
shout: Entity.computed(Upper, (d) => d.name.toUpperCase() as z.infer<typeof Upper>),

// every generator ended like this:
id: () => crypto.randomUUID() as z.infer<typeof OrganizationId>,

Both casts existed because ComputedField.from and Generators demanded the branded output — while both values go straight to a parser. recompute runs every computed value through fromSchema(f.schema), and generated values are spread into make, which validates against the input schema. The runtime proves the unbranded form; only the type demanded the branded one. The ceremony was pervasive enough that branded-fields.md had a section blessing it.

The change

Two type positions. Zero runtime change.

  • ComputedField.from (and computed()) return z.input<T> instead of z.infer<T>.
  • Generators / AsyncGenerators map over the field schemas and return z.input<S[K]>. GeneratedOf is retired; its measurement comments moved onto Generators.
// after
shout: Entity.computed(Upper, (d) => d.name.toUpperCase()),
id: () => crypto.randomUUID(),

Measured before designing:

  • a brand is output-only ($ZodBranded<…, "out">), so z.input of a branded schema is the plain primitive;
  • the castless form compiles and a wrong type is still rejected — the tie to the field's own schema survives;
  • non-breaking: a branded value assigns to its unbranded input, so every existing cast keeps compiling;
  • soundness holds for every legal field kind — including entity classes as fields, where z.input is the instance itself, so nothing loosens there at all.

Explicitly not changed

  • PatchOf stays branded. update also funnels through make, so the same loosening was possible — and rejected. A patch is caller data naming specific fields, and the brands are what stop update({ slug: someName }) from compiling. Mint helpers are the blessed tool there.
  • Invariant's d stays InputOf<S> — it consumes validated data, produces nothing.

Both rulings are recorded in the source so the next reader doesn't "finish the job".

One narrowing, pinned

A generator for a field that is both .optional() and generated was an optional key and is now required (it may return undefined). The combination occurs nowhere known. Stated in the changeset, and pinned by a @ts-expect-error proven load-bearing — removing it fails test:types with TS2741 at that exact line.

Docs — and the audit's two docs-only findings ride along

  • branded-fields.md's "two blessed patterns" loses its second pattern. Producers need nothing now; the section becomes parse at boundaries; producers are typed as the schema's input and validated on every construction. The mint-helper convention the spec files invented is promoted to the documented way to produce branded values everywhere else (const name = (v: string) => DisplayName.parse(v) — a named parse, not a cast).
  • The typed entry for make. make(state: unknown) is deliberate (rehydration is lenient), but the typed spellings were unnamed: satisfies Entity.Input<typeof X> restores every field-name check at a literal call site — shown paired with mint helpers, since the values must be branded too — and X.factory({}) is the fully-typed create function when nothing is generated. Both now documented in entry-points.md and persist-and-rehydrate.md, both scratch-compiled against the built package before publication.
  • The producer-cast sweep cleared docs and both READMEs (16 deleted, patch/mint casts deliberately kept). examples/billing-domain lost its six.
  • A tooling fact found on the way, pinned in CLAUDE.md: VitePress's dead-link check does not validate anchors whose headings contain em dashes — the link ships broken and green. Measured when this branch's first heading did exactly that.

Test plan

  • Four type-level guards: castless from compiles, castless generator compiles, a wrong type is still rejected, and a branded (cast) return still compiles — the back-compat pin
  • Shouty.factory({}) pins the typed-entry claim; the optional-generated narrowing pinned as above
  • Runtime suite untouched and green — no behaviour changed, so no runtime test changed
  • The four-step consumer gate (emit on 7.0.2 and 5.9.3, then type-check the emitted .d.ts on 5.9.3): green with the casts still present (the non-breaking proof at consumer scale), then green again castless; emitted declarations carry no dangling names
  • format --check · lint · typecheck · test · knip · build — green in CI order, uncached

Follow-up, parked deliberately

~30 casts remain in packages/entity/src/*.spec.ts / *.test-d.ts — now the only casts left in the repo, and never in any task's scope. They compile by design and incidentally extend back-compat coverage; a small standalone change should either sweep them or designate one file the deliberate back-compat net.

🤖 Generated with Claude Code

Copilot AI lite review requested due to automatic review settings August 9, 2026 09:05

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

Adjusts the library’s type surface so producer callbacks (computed field derivations and factory generators) return the schema’s input type rather than the branded output type, eliminating pervasive, redundant as z.infer<...> casts while preserving runtime behavior (values are still validated by the relevant schema parsers).

Changes:

  • Update computed-field and factory-generator type positions to return z.input<Schema> instead of z.infer<Schema>, removing the need for author-side casts.
  • Retire the internal GeneratedOf helper in favor of a direct generator mapping over field schemas.
  • Sweep docs/examples to remove producer casts and expand guidance around mint helpers and typed entry points (factory({}), satisfies Entity.Input<...>), plus add type-level regression tests and a changeset.

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
README.md Updates top-level README example to remove computed producer cast.
packages/entity/src/types.ts Changes Generators / AsyncGenerators to return z.input per field schema; removes GeneratedOf; updates related commentary.
packages/entity/src/types.test-d.ts Updates type-level tests to reflect removal of GeneratedOf.
packages/entity/src/entity.ts Updates computed-field parsing commentary to reflect input-typed producers.
packages/entity/src/entity.test-d.ts Adds type-level coverage for castless computed/generators and the optional+generated narrowing.
packages/entity/src/computed.ts Changes ComputedField.from / computed() return type to z.input<T>.
packages/entity/README.md Updates package README example to remove computed producer cast.
examples/billing-domain/src/root.ts Removes computed-field cast and drops unused z type import.
examples/billing-domain/src/organization.ts Removes computed-field cast and drops unused z type import.
examples/billing-domain/src/index.ts Removes generator casts; simplifies now() and drops unused type-only imports.
docs/tutorial/getting-started.md Removes generator casts in tutorial and adds guidance clarifying generator vs caller-field branding.
docs/reference/entry-points.md Documents generator input typing, factory({}) for no-generated entities, and satisfies Entity.Input<...> pattern for make.
docs/reference/declaration.md Removes computed-field casts from reference examples.
docs/index.md Removes computed-field cast from docs landing example.
docs/how-to/test-domain-logic.md Reworks guidance toward mint helpers; removes generator casts; improves narrative around deterministic tests.
docs/how-to/persist-and-rehydrate.md Adds satisfies Entity.Input<...> guidance for hand-built rows.
docs/how-to/model-an-aggregate.md Removes computed-field cast from aggregate example.
docs/explanation/branded-fields.md Reframes “where brands are minted” and removes the producer-cast pattern; promotes mint-helper approach.
docs/examples/billing-domain.md Removes computed/generator casts in the documentation example.
CLAUDE.md Records the measured VitePress heading-anchor/em-dash behavior to avoid broken anchors.
.changeset/castless-producers.md Adds a minor-release changeset describing the type-only producer loosening and the optional+generated narrowing.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread docs/how-to/test-domain-logic.md Outdated
@btravers
btravers merged commit 573d847 into main Aug 9, 2026
13 checks passed
@btravers
btravers deleted the worktree-feat-castless-producers branch August 9, 2026 09:26
btravers added a commit that referenced this pull request Aug 9, 2026
Convert the tutorial's four remaining casts to mint helpers, matching
the castless-producer convention PR #50 set elsewhere, and rewrite the
asymmetry paragraph it made stale. Harmonize the how-to (and one
reference) import callouts to state domain vocabulary is the reader's
own, the way test-domain-logic.md's callout already does.
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