feat: type computed and generator returns as the schema's input - #50
Merged
Conversation
There was a problem hiding this comment.
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 ofz.infer<Schema>, removing the need for author-side casts. - Retire the internal
GeneratedOfhelper 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.
This was referenced Aug 9, 2026
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.
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.
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
Both casts existed because
ComputedField.fromandGeneratorsdemanded the branded output — while both values go straight to a parser.recomputeruns every computed value throughfromSchema(f.schema), and generated values are spread intomake, which validates against theinputschema. The runtime proves the unbranded form; only the type demanded the branded one. The ceremony was pervasive enough thatbranded-fields.mdhad a section blessing it.The change
Two type positions. Zero runtime change.
ComputedField.from(andcomputed()) returnz.input<T>instead ofz.infer<T>.Generators/AsyncGeneratorsmap over the field schemas and returnz.input<S[K]>.GeneratedOfis retired; its measurement comments moved ontoGenerators.Measured before designing:
$ZodBranded<…, "out">), soz.inputof a branded schema is the plain primitive;z.inputis the instance itself, so nothing loosens there at all.Explicitly not changed
PatchOfstays branded.updatealso funnels throughmake, so the same loosening was possible — and rejected. A patch is caller data naming specific fields, and the brands are what stopupdate({ slug: someName })from compiling. Mint helpers are the blessed tool there.Invariant'sdstaysInputOf<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()andgeneratedwas an optional key and is now required (it may returnundefined). The combination occurs nowhere known. Stated in the changeset, and pinned by a@ts-expect-errorproven load-bearing — removing it failstest:typeswithTS2741at 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).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 — andX.factory({})is the fully-typed create function when nothing is generated. Both now documented inentry-points.mdandpersist-and-rehydrate.md, both scratch-compiled against the built package before publication.examples/billing-domainlost its six.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
fromcompiles, castless generator compiles, a wrong type is still rejected, and a branded (cast) return still compiles — the back-compat pinShouty.factory({})pins the typed-entry claim; the optional-generated narrowing pinned as above.d.tson 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 namesformat --check·lint·typecheck·test·knip·build— green in CI order, uncachedFollow-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