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.
Table of Contents
- 1. The problem with duplicated validation rules
- 2. Defining a Zod schema as the single source of truth
- 3. Connecting VeeValidate field components to the Zod resolver
- 4. Per-field error display
- 5. Comparison to plain VeeValidate without a schema library
- 6. Validating nested objects and arrays
- 7. Async validation with Zod and VeeValidate
- 8. Type-safe submit handling
- 9. Partial schemas for edit forms
- 10. Summary
- 11. FAQ
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