VeeValidate Combined with Zod for Vue Form Validation
AI generated
{ }
Vue · VeeValidate · Zod
VeeValidate + Zod
One schema for validation rules and TypeScript types

A Zod schema describes at once which values a form field may accept and what TypeScript type the result has after successful validation. Combined with VeeValidate through a Zod resolver, this creates a single source of truth for forms that would otherwise easily drift apart between validation logic and type definitions.

14 min read VeeValidate Zod

1. The problem with duplicated validation rules

Plain VeeValidate without a schema library usually defines validation rules directly on each field, either through built-in rules like required and email or through individual validator functions. The TypeScript type of the form value gets defined separately, independent of that, usually as its own interface or type that has to be kept in sync with the validation rules by hand. If a rule changes, say a new minimum length for a password field, that change has to be applied in two places, the validation logic and the type definition, which easily drifts apart over time.

Zod solves this by having a single schema drive both the validation rules and the TypeScript type inference. From a Zod schema, z.infer<typeof schema> automatically derives the matching TypeScript type, so validation rules and type definition are forced to stay in sync, since there's only one single source both are generated from.

2. Defining a Zod schema as the single source of truth

A typical Zod schema for a registration form combines primitive validations like z.string().min(2) for a name with more specialized ones like z.string().email() for an email address. Zod also provides mechanisms for cross-field validation through .refine(), for example to check that a password confirmation field matches the actual password field, something that plain VeeValidate would usually require a separate, hand-written cross-field validator function for.

Once the schema is defined, it can be used both for the form validation itself and anywhere else in the code that expects the same data shape, for example an API function that sends the validated form data to a backend. This reusability beyond pure form validation is one of the main practical advantages over isolated VeeValidate rules, which only make sense within the context of that particular form.


// src/schemas/registerSchema.ts
import { z } from 'zod'

export const registerSchema = z
  .object({
    name: z.string().min(2, 'Name must be at least 2 characters'),
    email: z.string().email('Please enter a valid email address'),
    password: z.string().min(8, 'Password must be at least 8 characters'),
    passwordConfirm: z.string(),
  })
  .refine((data) => data.password === data.passwordConfirm, {
    message: 'Passwords do not match',
    path: ['passwordConfirm'],
  })

// Automatically derived TypeScript type, always in sync with the schema
export type RegisterFormData = z.infer<typeof registerSchema>

3. Connecting VeeValidate field components to the Zod resolver

The bridge between Zod and VeeValidate is the @vee-validate/zod package with its toTypedSchema() function. This function converts a Zod schema into the format VeeValidate's useForm() composable expects as its validationSchema. From that point on, VeeValidate handles the actual form orchestration, meaning tracking field values, touched and dirty states, and triggering validation on blur or submit events, while Zod stays exclusively responsible for the actual validation logic.

Inside the form component, individual fields are wired up through VeeValidate's useField() or the declarative <Field> component, with the field name needing to match the corresponding key in the Zod schema exactly, so VeeValidate can map the right sub-validation to the right field. This coupling through the field name is the only point where the schema and the template need to stay in sync; the resolver handles everything else automatically.


<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { registerSchema, type RegisterFormData } from '@/schemas/registerSchema'

const { handleSubmit, errors, defineField } = useForm<RegisterFormData>({
  validationSchema: toTypedSchema(registerSchema),
})

const [email, emailAttrs] = defineField('email')
const [password, passwordAttrs] = defineField('password')

const onSubmit = handleSubmit((values) => {
  // values is already fully typed as RegisterFormData
  console.log(values.email)
})
</script>

<template>
  <form @submit="onSubmit">
    <input v-model="email" v-bind="emailAttrs" type="email" />
    <span>{{ errors.email }}</span>

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

4. Per-field error display

VeeValidate exposes error messages through the errors object from useForm(), where each key matches the corresponding field name and the value holds the readable error message generated by Zod, provided the schema defines its own message like in the example above. These error messages can be displayed directly under the relevant field in the template, with no extra translation layer needed between Zod and the display.

For cross-field validations like the password check in the example above, the path parameter in .refine() ensures the error gets attributed to the right field, in this case passwordConfirm, instead of showing up as a general, non-field-specific form error. Without that parameter, Zod would report the error at the object level, which in practice tends to be impractical for per-field display, since it wouldn't be clear which input the message should appear under.

5. Comparison to plain VeeValidate without a schema library

Plain VeeValidate without Zod usually defines rules through the built-in rule syntax or individual functions directly on the field, which works well and is quick to implement for simple forms with a few independent fields. The downside shows up with more complex forms involving cross-field dependencies, nested object structures, or a need for automatic TypeScript type inference, where plain VeeValidate requires additional, manually maintained type definitions.

Combining it with Zod pays off especially once a project already consistently uses TypeScript and form validation shouldn't be treated in isolation but instead ties into the same data structures used for API calls or state management elsewhere. For very small, one-off forms with a single text field, the extra schema overhead can feel unnecessary; a simple built-in VeeValidate rule is usually plenty there.

6. Validating nested objects and arrays

Another advantage of Zod shows up with more complex form structures involving nested objects or repeatable field groups, for example an address list with multiple entries. Zod models such structures natively through z.object() and z.array(), including validation of each individual array element against its own sub-schema. VeeValidate supports dynamic field lists through useFieldArray(), whose values can be validated directly against such an array schema, without writing separate validation rules for every possible array length.

In plain VeeValidate without a schema library, comparable nested validation would usually have to be replicated through nested object paths in the rule definition, which quickly gets unwieldy once more than one level of nesting is involved. Zod, by contrast, keeps this structure declarative and readable, because the schema itself can be composed recursively from smaller sub-schemas.

7. Async validation with Zod and VeeValidate

Zod also supports asynchronous validation functions through .refine(), for example to check whether a chosen username is already taken by running an API request inside the refine function and awaiting its result. VeeValidate automatically detects, through toTypedSchema(), that it's dealing with an asynchronous validation and waits for the promise accordingly before updating the field's validation status.

With async refine functions, it's important not to trigger validation on every single keystroke, since that would send a new API request for every character typed. VeeValidate's configuration options for when validation runs, for example only on blur instead of on every input, can be set independently of the Zod schema and should be chosen deliberately for async validations to avoid unnecessary server load.

8. Type-safe submit handling

The biggest practical win shows up at submit time: since VeeValidate's handleSubmit() knows the Zod schema's return type, the values object inside the submit callback is already fully typed, with no manual type conversion or casts. Every access to a field is checked by the TypeScript compiler, and a typo in a field name gets caught at compile time instead of only showing up as an undefined value at runtime.

This effect compounds when the same RegisterFormData type is also used by the function that sends the data to an API. If the Zod schema changes, say a new required field gets added, the TypeScript compiler immediately flags every place in the code that hasn't accounted for that new type yet, surfacing inconsistencies between the form and the API layer before the very first test run.

9. Partial schemas for edit forms

A common real-world case is an edit form where, unlike a creation form, not every field must be filled in, since existing values are allowed to stay unchanged. Instead of hand-maintaining a completely separate schema, Zod provides a .partial() method that automatically derives a variant of an existing schema where every field becomes optional, without losing the original validation rules for fields that are actually filled in.

This derived partial schema can be passed to VeeValidate through toTypedSchema() just like the original full schema, so a creation form and an edit form follow the same underlying rules without duplicating validation logic. If a rule in the base schema changes later, say a new minimum length for the name field, that change automatically applies to both forms, since the partial schema is always derived from the current base schema and never exists independently of it.

Aspect Plain VeeValidate VeeValidate + Zod
Rule definition per field individually centralized in the schema
TypeScript types manually maintained automatic via z.infer
Cross-field rules custom validator function .refine() inside the schema
Reuse outside the form barely possible schema usable everywhere
Nested structures laborious to replicate native via z.object/z.array

Mironsoft

Vue architecture, Composition API, and Nuxt performance

Vue applications that don't get more complicated with every feature?

We review existing Vue and Nuxt projects for unstructured composables, unnecessary reactivity, and bloated bundles, then build an architecture that absorbs new features without making the codebase harder to follow.

Architecture Review

Checking composables, state management, and component structure for maintainability.

Performance Audit

Systematically optimizing reactivity overhead, bundle size, and Nuxt rendering strategy.

Nuxt Integration

Building robust, type-safe SSR/SSG setup and API integration.

10. Summary

VeeValidate + Zod: key takeaways at a glance

Core idea

one Zod schema supplies validation rules and the TypeScript type at once

Connection

toTypedSchema() from @vee-validate/zod bridges the two

Error display

errors object per field name, refine() with path for cross-field errors

Payoff

worth it for TypeScript projects with more complex or reused data shapes

11. FAQ: VeeValidate + Zod: key takeaways at a glance

1Do I need Zod to use VeeValidate?
No, VeeValidate works standalone with built-in rules. Zod is an optional addition that pays off especially in TypeScript projects with more complex forms.
2How do I connect a Zod schema to VeeValidate?
Through the toTypedSchema() function from the @vee-validate/zod package, which converts the Zod schema into the validationSchema format expected by useForm().
3Do validation rules and the TypeScript type stay in sync automatically?
Yes, because the type is derived directly from the schema through z.infer. A change to the schema automatically affects the derived type.
4How do I validate that two fields match, like a password confirmation?
Through .refine() at the object level in the Zod schema, using the path parameter to specify which field the error should be attributed to.
5Does Zod support async validation, like an availability check?
Yes, through an async function inside .refine(). VeeValidate detects this automatically and waits for the promise before updating the field's status.
6How do I display error messages in the template?
Through the errors object from useForm(), which holds the matching Zod-generated message for each field name, referenceable directly in the template.
7Can I validate nested forms with Zod?
Yes, through z.object() for nested objects and z.array() for repeatable structures, combined with VeeValidate's useFieldArray() for dynamic field lists.
8Is the combination worth it for small, simple forms too?
For a single text field with a simple rule, plain VeeValidate is usually sufficient and quicker to implement, without the schema overhead.
9Can the same Zod schema be used for API validation as well?
Yes, that's one of the main advantages. The same schema can be used both in the form and anywhere else that expects the same data shape.
10Does the field name in the template need to exactly match the schema key?
Yes, VeeValidate maps field values to the matching schema key by field name; a mismatch means validation won't apply to that field.