| |

Vue.js 63 🟢 Form Validation with VeeValidate or Zod

⏱️ Estimated read: 13 min | |

Form validation in Vue 3 is not a built-in feature. The framework gives you reactivity and v-model, but the rules, the error state, the submission gating, and the async validation are left to the application. VeeValidate is the library that fills this gap. It is not a schema validator itself—it is the form state manager that accepts validation rules from any source, including its own built-in rules, Yup schemas, or Zod schemas. The @vee-validate/zod package is the bridge that converts a Zod schema into a form VeeValidate can execute, giving you the type safety of Zod with the field-level reactivity of VeeValidate.

The distinction matters because Zod and VeeValidate solve different problems. Zod defines the shape and constraints of the data. VeeValidate tracks which fields are touched, dirty, and invalid, and decides when to run the validation. A Zod schema says “email must be a valid email address.” VeeValidate says “run that check on blur, show the error under the field, and disable the submit button until every field passes.” The integration—toTypedSchema—is what connects the two, and it is the piece that most tutorials gloss over.

Key point: VeeValidate manages form state and validation timing; Zod defines the validation rules and the TypeScript types. toTypedSchema from @vee-validate/zod converts a Zod schema into the format VeeValidate expects, and the resulting form values are typed from the schema, so a typo in a field name is a compile-time error.


Why VeeValidate and Zod are used together

The form state problem. A Zod schema can validate a plain object in one call. But a form is not a plain object—it has fields that are touched, dirty, and invalid at different times. A user who has not yet interacted with a field should not see its error. A field that was valid and became invalid should show the error immediately. VeeValidate tracks this state. Zod does not, and it is not designed to.

The type inference problem. Zod’s z.infer extracts the TypeScript type from a schema. VeeValidate’s toTypedSchema uses that type to type the values object, the handleSubmit callback argument, and the field names. If the schema has an email field and the form tries to access values.emial, the compiler rejects it. This is the “type-safe form” that the @vee-validate/zod documentation describes .

The validation provider problem. VeeValidate supports multiple validation providers. Yup was the original, but Zod has become the more common choice for TypeScript projects because of its inference and its smaller API surface. The @vee-validate/zod package is the adapter. Without it, VeeValidate does not know how to execute a Zod schema or how to extract the error messages from it .

The toTypedSchema problem. The adapter’s only job is the toTypedSchema function. It takes a Zod schema and returns an object that VeeValidate’s core understands. The object has a parse method that VeeValidate calls with the current form values, and the result is either the validated data or the error map. The function is the entire integration surface, and it is the line that is most often forgotten when developers try to use Zod with VeeValidate without the adapter.


a. Setting up VeeValidate with a Zod schema

The setup requires three packages: vee-validate for the form state, zod for the schema, and @vee-validate/zod for the adapter. The form is created with useForm, and the schema is passed through toTypedSchema.

// Import the form hook from vee-validate
import { useForm } from 'vee-validate'
// Import the Zod adapter
import { toTypedSchema } from '@vee-validate/zod'
// Import Zod for the schema definition
import { z } from 'zod'

// Define the schema with Zod
// Each field has its own validation rules and error messages
const schema = z.object({
  email: z.string().min(1, 'Email is required').email('Must be a valid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})

// Create the form with the typed schema
// The values are typed from the schema
const { defineField, handleSubmit, errors } = useForm({
  validationSchema: toTypedSchema(schema),
})

// Define the email field
// The first element is the value ref
// The second element is the props object (onBlur, onInput, etc.)
const [email, emailProps] = defineField('email')

// Define the password field
const [password, passwordProps] = defineField('password')

// The submit handler receives the validated values
// The values are typed from the schema
const onSubmit = handleSubmit((values) => {
  // values.email is typed as string
  // values.password is typed as string
  console.log(values)
})

The defineField function returns a tuple. The first element is the value ref, which is bound with v-model. The second element is an object containing the event handlers and attributes that VeeValidate uses to track the field. The v-bind="emailProps" in the template attaches those handlers, which is how VeeValidate knows when the field is touched, blurred, or changed .

The template binds the value and the props to the input, and displays the error message from the errors object:

<template>
  <form @submit="onSubmit">
    <!-- Bind the value ref and the VeeValidate props -->
    <input v-model="email" v-bind="emailProps" type="email" />
    <!-- Show the error for the email field -->
    <span>{{ errors.email }}</span>

    <input v-model="password" v-bind="passwordProps" type="password" />
    <span>{{ errors.password }}</span>

    <button type="submit">Submit</button>
  </form>
</template>

The errors object is reactive. When the validation runs and the email field fails, errors.email is populated with the message from the Zod schema. When the field becomes valid, the error is cleared.


b. The @vee-validate/zod adapter and type inference

The adapter’s value is the type inference it provides. The schema defines the shape, and the form’s values object is typed from the schema. The handleSubmit callback receives the typed values, and the TypeScript compiler catches field name mismatches.

import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'

// The schema defines the shape and the constraints
const schema = z.object({
  email: z.string().email(),
  name: z.string().optional(),
})

const { handleSubmit, values } = useForm({
  validationSchema: toTypedSchema(schema),
})

// values.email is typed as string
// values.name is typed as string | undefined
// values.emial is a compile-time error

handleSubmit((submitted) => {
  // submitted.email is string
  // submitted.name is string | undefined
  submitted.email.endsWith('@gmail.com') // OK
  // submitted.name.length // Type error: name may be undefined
})

The toTypedSchema function infers the input type and the output type from the Zod schema. The input type is what the form accepts, and the output type is what the handleSubmit callback receives after validation. For a schema with a z.preprocess, the input type can be unknown while the output type is the validated type . This distinction is useful when the form accepts a string but the schema transforms it to a number.

The meta object is the other piece of state that VeeValidate provides. It contains valid, dirty, touched, and pending flags for the form. The meta.valid flag is what disables the submit button until every field passes:

<button type="submit" :disabled="!meta.valid">Submit</button>

The meta.valid flag is updated after every validation run. It is the form-level equivalent of the per-field errors object.


c. Field-level validation and the defineField options

The form-level schema is the common case, but VeeValidate also supports field-level validation. A single field can have its own Zod schema, passed through toTypedSchema and assigned to the field’s rules.

import { useField } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'

// A schema for a single field
const emailSchema = toTypedSchema(
  z.string().min(1, 'Required').email('Invalid email')
)

// useField creates the field with its own validation
const { value, errorMessage } = useField('email', emailSchema)

The useField function returns the value ref and the error message directly. The field-level schema is useful when the validation rules are not part of a form-level schema—a standalone search input, a filter field, or a component that is used outside a form context .

The defineField options control the validation timing. By default, VeeValidate validates on model update (every keystroke). The validateOnModelUpdate option can be set to false to validate only on blur or submit:

// Validate only when the field loses focus
const [email, emailProps] = defineField('email', {
  validateOnModelUpdate: false,
})

The global configure function sets the default timing for every field in the application. The validateOnBlur, validateOnChange, validateOnInput, and validateOnModelUpdate flags control which events trigger validation . The per-field options override the global configuration, which is useful when one field needs different behavior from the rest.


Complete Example Session

The following session builds a login form with a Zod schema, VeeValidate’s useForm and defineField, type inference, and a submit button that is disabled until the form is valid.

// ============================================
// PART 1: THE SCHEMA
// ============================================
import { z } from 'zod'

// Define the login schema
// The email field must be a valid email address
// The password field must be at least 8 characters
const loginSchema = z.object({
  email: z.string().min(1, 'Email is required').email('Must be a valid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
})
// ============================================
// PART 2: THE FORM SETUP
// ============================================
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'

// Create the form with the typed schema
const { defineField, handleSubmit, errors, meta } = useForm({
  // toTypedSchema converts the Zod schema into a VeeValidate schema
  validationSchema: toTypedSchema(loginSchema),
})

// Define the email field
// emailProps contains the validation event handlers
const [email, emailProps] = defineField('email')

// Define the password field
const [password, passwordProps] = defineField('password')

// The submit handler receives the validated values
// values.email is string, values.password is string
const onSubmit = handleSubmit((values) => {
  console.log('Submitting:', values)
})
<!-- ============================================
     PART 3: THE TEMPLATE
     ============================================ -->
<template>
  <form @submit="onSubmit">
    <div>
      <label for="email">Email</label>
      <!-- Bind the value and the VeeValidate props -->
      <input id="email" v-model="email" v-bind="emailProps" type="email" />
      <!-- Show the error message from the schema -->
      <span v-if="errors.email" class="error">{{ errors.email }}</span>
    </div>

    <div>
      <label for="password">Password</label>
      <input id="password" v-model="password" v-bind="passwordProps" type="password" />
      <span v-if="errors.password" class="error">{{ errors.password }}</span>
    </div>

    <!-- Disable the submit button until the form is valid -->
    <button type="submit" :disabled="!meta.valid">Log in</button>
  </form>
</template>

The three parts cover the schema definition, the form setup with toTypedSchema and defineField, and the template that binds the values, displays the errors, and disables the submit button. What the session demonstrates is that the Zod schema is the single source of truth for the validation rules and the types, and VeeValidate is the layer that applies those rules to the form’s fields.


Quick Reference

Package Setup

PackagePurpose
vee-validateForm state and validation timing
zodSchema definition and type inference
@vee-validate/zodAdapter (toTypedSchema)

Core Functions

FunctionPurpose
useForm({ validationSchema })Create the form context
defineField('name')Bind a field to the form
handleSubmit(fn)Validate and submit
toTypedSchema(schema)Convert Zod to VeeValidate schema

Form State

PropertyMeaning
errors.fieldError message for the field
meta.validAll fields pass
meta.dirtyAny field has changed
meta.touchedAny field has been blurred

Validation Timing

OptionDefaultEffect
validateOnModelUpdatetrueValidate on every keystroke
validateOnBlurtrueValidate on blur
validateOnChangetrueValidate on change

Best Practices

✅ Do This:

// Use toTypedSchema to connect Zod and VeeValidate
const { defineField } = useForm({ validationSchema: toTypedSchema(schema) })

// Bind the field props to the input
const [email, emailProps] = defineField('email')
// <input v-model="email" v-bind="emailProps" />

// Use meta.valid to disable the submit button
// <button :disabled="!meta.valid">Submit</button>

❌ Don’t Do This:

// Pass a Zod schema directly to useForm without the adapter
useForm({ validationSchema: schema }) // VeeValidate cannot execute it

// Forget to bind the props
const [email] = defineField('email') // no validation triggers

// Access fields that do not exist in the schema
values.emial // compile-time error with toTypedSchema

Common Pitfalls

PitfallWhy It HappensFix
Schema not validatingPassed Zod schema directlyWrap in toTypedSchema
Errors not showingDid not bind v-bind="fieldProps"Bind the props object
Submit not disabledDid not use meta.validUse the form meta flag
Type error on valuesField name typoThe schema types catch it
Transform breaks meta.requiredKnown VeeValidate issueDo not rely on meta.required with transforms

Real-World Examples

1. Basic schema

const schema = z.object({ email: z.string().email() })

2. Typed form

const { handleSubmit } = useForm({ validationSchema: toTypedSchema(schema) })

3. Field binding

const [email, emailProps] = defineField('email')

4. Error display

<span>{{ errors.email }}</span>

5. Disable submit

<button :disabled="!meta.valid">Submit</button>

6. Field-level validation

const { errorMessage } = useField('email', toTypedSchema(z.string().email()))

7. Lazy validation

const [email, props] = defineField('email', { validateOnModelUpdate: false })

8. Global timing

import { configure } from 'vee-validate'
configure({ validateOnModelUpdate: false })

9. Optional field

z.object({ name: z.string().optional() })

10. Submit values

handleSubmit((values) => { console.log(values.email) })

Visual

The Integration

┌──────────────────────────────────────────────┐
│  ZOD SCHEMA                                  │
│  defines shape, rules, types                 │
│         │                                    │
│         ▼                                    │
│  toTypedSchema                               │
│  converts to VeeValidate format              │
│         │                                    │
│         ▼                                    │
│  useForm({ validationSchema })               │
│  creates form context                        │
│         │                                    │
│         ▼                                    │
│  defineField('email')                        │
│  returns [value, props]                      │
│         │                                    │
│         ▼                                    │
│  <input v-model="email" v-bind="props" />    │
└──────────────────────────────────────────────┘

Form State

┌──────────────────────────────────────────────┐
│  errors.email    → "Must be a valid email"   │
│  errors.password → "Password must be 8 chars"│
│  meta.valid      → false (until all pass)    │
│  meta.dirty      → true (after any change)   │
│  meta.touched    → true (after any blur)     │
└──────────────────────────────────────────────┘

Type Inference

┌──────────────────────────────────────────────┐
│  const schema = z.object({ email: z.string() })│
│         │                                    │
│         ▼                                    │
│  useForm({ validationSchema: toTypedSchema(schema) })│
│         │                                    │
│         ▼                                    │
│  values.email → string                       │
│  values.emial → compile-time error           │
└──────────────────────────────────────────────┘

Summary

ItemValue
Form state managerVeeValidate
Schema validatorZod
Adapter@vee-validate/zod
Conversion functiontoTypedSchema
Form creationuseForm
Field bindingdefineField
Submit handlerhandleSubmit
Form validitymeta.valid
Error accesserrors.field

Key takeaways:

  • VeeValidate and Zod solve different problems. Zod defines the validation rules and the types. VeeValidate manages the form state, the field lifecycle, and the validation timing. The adapter connects them .
  • toTypedSchema is the bridge. Without it, VeeValidate cannot execute a Zod schema. The function converts the schema into the format that VeeValidate’s core expects, and it provides the type inference that makes the form values type-safe .
  • defineField returns the value and the props. The value is bound with v-model, and the props are bound with v-bind. The props contain the event handlers that trigger validation. Forgetting the props means the validation never runs .
  • meta.valid is the form-level flag. It is true only when every field passes validation. It is the correct value for disabling the submit button until the form is ready.
  • Field-level validation uses useField with a schema. The same toTypedSchema function converts a single-field Zod schema for use outside a form context .
  • Validation timing is configurable. The validateOnModelUpdate option controls whether validation runs on every keystroke or only on blur. The global configure function sets the default for the application .

Remember: VeeValidate is the form state manager, Zod is the schema validator, and toTypedSchema is the adapter that connects them. The schema is the single source of truth for the rules and the types. The form tracks the touched, dirty, and valid state of each field. The defineField function returns the value ref and the props object, and both must be bound to the input for validation to work. The meta.valid flag is the form-level gate that prevents submission until every field passes. The integration is not a replacement for either library—it is the layer that makes them work together.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!