fix(ui): form() coerces FormData per @vertz/schema leaves [#2771]#2810
Merged
viniciusdacal merged 12 commits intomainfrom Apr 18, 2026
Merged
fix(ui): form() coerces FormData per @vertz/schema leaves [#2771]#2810viniciusdacal merged 12 commits intomainfrom
viniciusdacal merged 12 commits intomainfrom
Conversation
Phase 1 of #2771 — exposes the existing private `_element` field on `ArraySchema` so external consumers can introspect the element schema without reaching into private state. Used by the upcoming `coerce.ts` utility (Phase 2) to walk a schema tree and coerce FormData values to schema-declared types in `form()`. Additive only; no behavior change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drops the gratuitous `as Schema<unknown>` cast flagged by Phase 1 review; preserves element-type information for downstream coerce.ts callers. Matches the pattern used by ObjectSchema.shape (no widening). Adds Phase 1 adversarial review at reviews/2771-form-coerce-field-types/. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds two pure helpers under packages/ui/src/form/coerce.ts:
- coerceLeaf(value, leafSchema): walks Optional/Default/Nullable/Refined
wrappers via _schemaType()+unwrap, then coerces by inner type:
Boolean → strict on/off/0/1/true/false set; Number/BigInt → numeric
string parsing with empty-string drop; Date → parseable string → Date.
String/Enum/Literal/Lazy/custom-adapter → passed through.
- coerceFormDataToSchema(formData, schema): dispatches on schema._schemaType()
to assemble a coerced object. Object → walk shape; Array of primitives →
formData.getAll().map(coerceLeaf); Array of objects → fall back to
formDataToObject({nested:true}) so dotted-index data is preserved.
Non-Vertz adapters and non-object schemas fall back to formDataToObject.
Utility stays internal (no public re-export). 60 BDD-style tests cover
every coerceLeaf table row plus the coerceFormDataToSchema scenarios from
the design doc; coverage 96.4% line / 95.4% branch.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
readLeafFromFormData now returns undefined for any non-string FormData value, mirroring the File-skip behavior of formDataToObject. Without this, a File entry on a Boolean-typed field would coerce to true via Boolean(file). File-typed schemas remain out of scope for this utility. Adds two tests (File on Boolean → false, File on Number → drop) and the Phase 2 adversarial review. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…alidation [#2771] Submit pipeline now uses coerceFormDataToSchema when the body schema is available, so checkbox booleans, numeric inputs, dates and bigints reach the SDK as their proper types. Blur revalidation calls coerceLeaf on the field value before validateField, so the revalidate-on-blur path agrees with the submit path. resolveFieldSchema is exported so form.ts can resolve a leaf schema once per blur without duplicating traversal logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a "FormData coercion" section to the Forms guide with a per-leaf coercion table (boolean/number/bigint/date/string and arrays of primitives), plus a brief mirror in the form() API reference linking back to the guide. Records the user-facing change in a patch changeset covering @vertz/ui and @vertz/schema. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…bject schemas [#2771] Phase 3 review found that a top-level .refine() / .superRefine() on the body schema silently disabled FormData coercion: neither wrapper exposed .unwrap(), so coerceFormDataToSchema fell through to the non-coerced fallback. The user pattern that breaks is the canonical cross-field validator, e.g. s.object({ password, confirm }).refine(...). This adds .unwrap() to RefinedSchema and SuperRefinedSchema, matching the existing pattern on Optional/Default/Nullable. coerceFormDataToSchema now reaches the inner ObjectSchema and walks its .shape as expected. Also addresses Phase 3 review: - Adds two coerce.test.ts cases (top-level refined / superRefined object). - Adds two form-coercion.test.ts E2E cases (refined object + custom no-_schemaType adapter regression guard). - Reuses the existing createMockFormElement helper pattern in the blur test instead of duplicating it inline. - Tightens docs: empty number strings drop the field but do not echo default() back into the SDK body. - Updates the changeset to mention the new unwrap() accessors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…alidation [#2771] Re-review caught a residual gap from the previous refine/superRefine fix: submitPipeline now coerces refined object schemas correctly, but blur revalidation went through resolveFieldSchema which read schema.shape directly without unwrapping the top-level wrapper. With s.object({...}).refine(), resolveFieldSchema returned undefined → the blur path validated the raw string and produced a stale "Expected number, received string" error that contradicted the post-coercion submit result. Extracts the existing intermediate-segment unwrap loop into a reusable unwrapToShape helper and applies it to the top-level schema as well as each intermediate path segment. Adds a TDD test in form-coercion.test.ts that proves blur revalidation on a refined number field clears the error after the user fixes the value. Also adds a docs <Note> calling out the remaining wrappers (.transform / .pipe / .catch / .brand / .readonly) that still disable top-level coercion, so users with those patterns aren't surprised. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Per .claude/rules/local-phase-workflow.md, reviews/ are working artifacts and should not be committed to main. The PR description summarizes them. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <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.
Summary
Fixes #2771.
form()now coerces FormData values to the leaf types declared inmeta.bodySchemabefore validation and submission, so checkboxes, numbers, dates, etc. round-trip correctly without each form having to write its owntransform.The same coercion runs at submit and on blur/change re-validation, so live field errors and submit errors agree.
Public API Changes
Additions:
ArraySchema.element— public getter exposing the element schema (used by coercion + downstream tools).RefinedSchema.unwrap()/SuperRefinedSchema.unwrap()— return the inner schema, matchingOptionalSchema/DefaultSchema/NullableSchema. Letsform()coerces.object(...).refine(...)and.superRefine(...)(top-level cross-field validation).Behavior change (no API surface):
form()calls schema-drivencoerceFormDataToSchema(fd, bodySchema)whenmeta.bodySchema._schemaType()is recognizable. Falls back toformDataToObject(fd, { nested: true })for custom adapters or wrappers we don't yet walk through.Per-leaf coercion rules:
s.boolean()trues.boolean()falses.boolean()"false","0","off"falses.number()"42"42s.number()""(empty)optional()validate)s.bigint()"9007199254740993"9007199254740993ns.date()"2026-04-18"Dates.string()"42""42"(never coerced)s.array(s.string())string[]fromgetAll()Out of scope (still disable coercion at top level):
.transform(),.pipe(),.catch(),.brand(),.readonly()— tracked as follow-ups (#2808 / #2809).Phases
feat(schema): add public ArraySchema.element getter+refactor(schema): type ArraySchema.element as Schema<T>feat(ui): add schema-driven coerce.ts utility for form()(64 BDD tests, full per-leaf + wrapper matrix)feat(ui): wire schema-driven coercion into form() submit and blur revalidation+fix(schema,ui): unwrap refine/superRefine+fix(ui): unwrap top-level wrappers in resolveFieldSchema for blur revalidation+ docs + changesetReviews
Two adversarial reviews per phase, all blockers and should-fix items resolved before push (local-only artifacts under
reviews/issue-2771/, removed before merge per.claude/rules/local-phase-workflow.md).Test plan
coerce.test.tstests (per-leaf + wrapper unwrap)form-coercion.test.tsBDD scenarios (submit + blur + custom adapter fallback)vtz test— 2396 passedvtz run typecheck— cleanvtz run lint— clean (warnings only, all pre-existing)🤖 Generated with Claude Code