Skip to content

fix(ui): form() coerces FormData per @vertz/schema leaves [#2771]#2810

Merged
viniciusdacal merged 12 commits intomainfrom
viniciusdacal/issue-2771
Apr 18, 2026
Merged

fix(ui): form() coerces FormData per @vertz/schema leaves [#2771]#2810
viniciusdacal merged 12 commits intomainfrom
viniciusdacal/issue-2771

Conversation

@viniciusdacal
Copy link
Copy Markdown
Contributor

Summary

Fixes #2771. form() now coerces FormData values to the leaf types declared in meta.bodySchema before validation and submission, so checkboxes, numbers, dates, etc. round-trip correctly without each form having to write its own transform.

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, matching OptionalSchema/DefaultSchema/NullableSchema. Lets form() coerce s.object(...).refine(...) and .superRefine(...) (top-level cross-field validation).

Behavior change (no API surface):

  • form() calls schema-driven coerceFormDataToSchema(fd, bodySchema) when meta.bodySchema._schemaType() is recognizable. Falls back to formDataToObject(fd, { nested: true }) for custom adapters or wrappers we don't yet walk through.

Per-leaf coercion rules:

Schema leaf FormData input Coerced value
s.boolean() checkbox checked true
s.boolean() checkbox absent false
s.boolean() "false", "0", "off" false
s.number() "42" 42
s.number() "" (empty) dropped (lets optional() validate)
s.bigint() "9007199254740993" 9007199254740993n
s.date() "2026-04-18" Date
s.string() "42" "42" (never coerced)
s.array(s.string()) repeated checkboxes string[] from getAll()

Out of scope (still disable coercion at top level): .transform(), .pipe(), .catch(), .brand(), .readonly() — tracked as follow-ups (#2808 / #2809).

Phases

  • Phase 1feat(schema): add public ArraySchema.element getter + refactor(schema): type ArraySchema.element as Schema<T>
  • Phase 2feat(ui): add schema-driven coerce.ts utility for form() (64 BDD tests, full per-leaf + wrapper matrix)
  • Phase 3feat(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 + changeset

Reviews

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

  • 64 coerce.test.ts tests (per-leaf + wrapper unwrap)
  • 12 form-coercion.test.ts BDD scenarios (submit + blur + custom adapter fallback)
  • vtz test — 2396 passed
  • vtz run typecheck — clean
  • vtz run lint — clean (warnings only, all pre-existing)
  • GitHub CI green

🤖 Generated with Claude Code

viniciusdacal and others added 12 commits April 18, 2026 12:35
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>
@viniciusdacal viniciusdacal merged commit 8d8976d into main Apr 18, 2026
6 checks passed
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.

form() does not coerce field types — boolean/number fields fail validation

1 participant