skip to content

In VeeValidate 4 with a toTypedSchema Zod schema, why are form values typed partial while handleSubmit's callback gets required fields, and when does the callback not run?

level: middleimportance: should knowfreq 27%

answer

  1. one form, two value types
  2. what the user is typing
  3. what the schema outputs
  4. an adapter package per library
  5. touch, count, validate, then call

basics

~20 s

toTypedSchema tells VeeValidate the schema's input type, a deep partial of what users are filling in, and its output type, what a valid parse returns. handleSubmit validates everything first and only calls your callback, with the output, when the form is valid.

solid answer

~40 s

Form values have two types: the **input** type while the user is typing, where anything can still be missing, and the **output** type the schema produces once it passes. `toTypedSchema` from `@vee-validate/zod` (or `/yup`, `/valibot`) exposes both, so `values.email` is possibly undefined while the `handleSubmit` callback receives the parsed output with required fields as plain strings. The handler returned by `handleSubmit` prevents the native submit, marks all fields touched, sets `isSubmitting`, increments `submitCount` and validates the whole form. If anything fails, your callback does not run; an optional second callback receives `{ values, errors, results }`. In 4.15 the Zod adapter peers on Zod 3.

code

ts · 22 lines
ts
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'

const { values, handleSubmit } = useForm({
  validationSchema: toTypedSchema(
    z.object({
      email: z.string().email(),
      password: z.string().min(8),
    }),
  ),
})

// values.email is string | undefined here
const onSubmit = handleSubmit(
  async (data) => {
    await createAccount(data.email, data.password) // both are string
  },
  ({ errors }) => {
    console.warn('Sign-up blocked', errors)
  },
)

go deeper

for a junior

Recall that the schema goes to useForm's validationSchema, wrapped by toTypedSchema from the adapter package, and that handleSubmit only calls you when the form is valid.

for a middle

Explain the input and output types, why values is partial and the submitted data is not, and the exact steps handleSubmit runs before your callback.

for a senior

Show the invalid-submit handler, isSubmitting and submitCount in real flows, and check the adapter's peer range against the schema library you install.

for a principal

Weigh adopting one schema library across forms and server code against the adapter coupling, knowing the adapter layer changes in the next major.

## Why one form needs two types While a user fills a sign-up form, every field can be empty, half-typed or missing. After validation passes, the data is exactly what the schema promises. VeeValidate calls this the **dual nature** of form values: | Type | When it applies | Example for `email: z.string().email()` | |---|---|---| | **input** | `values`, `setFieldValue`, `initialValues` | `email?: string` | | **output** | the `handleSubmit` callback | `email: string` | A plain `useForm<MyForm>()` interface can only express one of them: mark `email` optional and the submit callback must re-check it; mark it required and `values.email.endsWith(...)` compiles even though the field may be empty. ## What toTypedSchema does `toTypedSchema` is exported by the **adapter packages**, not by the core `vee-validate` package: - `@vee-validate/zod`, `@vee-validate/yup` and `@vee-validate/valibot` each wrap their library's schema. - The wrapper implements VeeValidate's typed-schema contract: `parse` runs the schema (the Zod adapter uses `safeParseAsync`, so async refinements work), `cast` fills defaults, `describe` reports whether a path is required. - Its TypeScript signature carries both types: the input as a deep partial of the schema's input, the output as the schema's output. - Schema defaults, such as `z.string().default('')`, become initial values, so you may not need a separate `initialValues` object. ## What handleSubmit does, in order `handleSubmit(onSuccess, onInvalid?)` returns a handler you bind to `@submit` or call yourself. Each call: 1. calls `preventDefault()` when it receives an event, so no `.prevent` modifier is needed; 2. marks every field `touched`, so errors gated on touched state appear; 3. sets `isSubmitting` to `true` and increments `submitCount`, whatever the result; 4. validates the whole form, with `pending` true while async rules run; 5. if valid, calls `onSuccess(values, actions)`: with a typed schema, `values` is the schema's parsed output, and `actions` offers `setErrors`, `setFieldError`, `resetForm` and similar helpers; 6. if invalid, skips `onSuccess` and calls `onInvalid({ values, evt, errors, results })` when provided; 7. sets `isSubmitting` back to `false` once the callback settles, waiting for a returned promise. `handleSubmit.withControlled(...)` is a variant that passes only values bound to fields, dropping extra keys that came in through `initialValues`. ## Using the form actions after a successful submit The second argument of the success callback is a set of **form actions** bound to this form: - `resetForm()` clears values to their initial state, empties errors and resets meta, which suits a form that stays on screen after sign-up; - `resetForm({ values })` resets to new values, making them the new baseline for `dirty`; - `setFieldError` and `setErrors` place messages on fields, for example when the account request is refused for one field; - `setFieldValue` and `setValues` change values from code without the user typing. These are the same functions `useForm` returns; receiving them in the callback keeps submit logic self-contained, for instance when the handler is defined in a separate module. ## Types without a schema library Without `toTypedSchema`, `useForm<SignUp>()` accepts a single interface and uses it for both values and submitted data. The docs show why that falls short: optional fields stay optional in the callback, so it must re-check them, and required ones are typed as present while the user is still typing. The typed schema removes that trade-off by deriving both types from the rules themselves. ## Version notes - `@vee-validate/zod` 4.15.1 declares `zod ^3.24.0` as its peer, so it targets Zod 3. Pairing it with a Zod 4 schema is outside what that adapter supports. - VeeValidate 5 is in beta and moves to Standard Schema, dropping the adapter packages; the stable 4.x line still uses `toTypedSchema`. ## Mistakes worth catching - Importing `toTypedSchema` from `vee-validate`: it lives in the adapter package for your schema library. - Re-checking `if (values.email)` inside the submit callback: with a typed schema the output already guarantees it. - Treating a skipped callback as a silent failure: pass an invalid-submit handler to focus the first error or log the attempt. - Adding `@submit.prevent` and also calling `preventDefault` yourself: the returned handler already does it for events.

  • What happens to isSubmitting if the success callback throws?
    The handler resets `isSubmitting` to `false` in both the resolved and the rejected path, so a failed request does not leave the button disabled. The error still propagates to whoever called the handler, so catch it if you want to show a message.
  • Why might a password-confirmation refine on the Zod object show its error late?
    The VeeValidate docs flag a known Zod behaviour: `refine` and `superRefine` on an object do not run while some of its keys are missing. Until every key is present, the mismatch rule is skipped. Seeding `initialValues` for every field keeps keys present.

saying these in an interview costs you the question

  • toTypedSchema is exported from the core vee-validate package.
  • Without toTypedSchema, the submit callback's values are already the schema's output type.
  • handleSubmit calls the callback anyway and passes the errors as an argument.
  • You must add @submit.prevent or the browser reloads the page.
  • submitCount only increases when a submission succeeds.