Decoupling Form Logic with VeeValidate and Zod Schemas
Stop scattering validation rules across your Vue components. Learn how to use VeeValidate with Zod schemas to create a single, type-safe source of truth for your form logic.
12 Aug 2025, 09:17 UTC

The Problem: Validation Logic Sprawl
In many Vue.js applications, validation logic ends up scattered across the UI. You might find a regex check in a template, a custom validation function in a script block, and a separate TypeScript interface to define the shape of the data. This fragmentation makes forms difficult to maintain: changing a field from "optional" to "required" requires updates in multiple places, and the UI often loses synchronization with the actual data types being sent to the API.
The most effective way to solve this is by moving the "source of truth" out of the component and into a schema. By combining VeeValidate (a Vue form library) with Zod (a TypeScript-first schema declaration library), you can define your validation rules once and let the library handle state management and type inference.
Centralizing Truth with Schema-Based Validation
VeeValidate's useForm composition API allows you to pass a validation schema as a configuration option. Instead of defining rules for every individual input, the schema acts as a blueprint for the entire form.
When using Zod, you are not just defining runtime checks; you are creating a type definition. Zod lets you infer the TypeScript type directly from the schema using z.infer<typeof schema>. This ensures that the data you extract from the form on submission matches the structure your backend expects, eliminating the need to maintain a separate manual interface.
Implementing a Type-Safe Form
To implement this, you need the vee-validate and zod packages, along with the @vee-validate/zod adapter. The following example demonstrates a user profile update form where the validation logic is decoupled from the Vue template.
<script setup lang=\"ts\">
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import * as z from 'zod';
// 1. Define the schema (The single source of truth)
const profileSchema = z.object({
username: z.string().min(3, 'Username must be at least 3 characters'),
email: z.string().email('Please enter a valid email address'),
age: z.number({ invalid_type_error: 'Age must be a number' }).min(18, 'Must be 18+'),
});
// 2. Infer the type from the schema for use in the app
type ProfileForm = z.infer<typeof profileSchema>;
// 3. Initialize the form with the Zod schema
const { errors, defineField, handleSubmit } = useForm({
validationSchema: toTypedSchema(profileSchema),
initialValues: {
username: '',
email: '',
age: undefined,
},
});
// defineField creates a model and a set of attributes for the input
const [username, usernameProps] = defineField('username');
const [email, emailProps] = defineField('email');
const [age, ageProps] = defineField('age');
const onSubmit = handleSubmit((values: ProfileForm) => {
console.log('Form submitted successfully:', values);
});
</script>
<template>
<form @submit=\"onSubmit\">
<div>
<input v-model=\"username\" v-bind=\"usernameProps\" placeholder=\"Username\" />
<span>{{ errors.username }}</span>
</div>
<div>
<input v-model=\"email\" v-bind=\"emailProps\" placeholder=\"Email\" />
<span>{{ errors.email }}</span>
</div>
<div>
<input v-model.number=\"age\" v-bind=\"ageProps\" type=\"number\" />
<span>{{ errors.age }}</span>
</div>
<button type=\"submit\">Save Profile</button>
</form>
</template>
Key Implementation Details
- toTypedSchema: This adapter is critical. It translates Zod's validation logic into a format VeeValidate understands while preserving TypeScript's type safety.
- defineField: This helper simplifies the connection between the schema and the HTML input, automatically handling the
blurandinputevents to trigger validation. - v-model.number: Since Zod expects a number for the age field, using the
.numbermodifier prevents the input from passing a string to the validator.
Trade-offs and Limitations
While schema-based validation is powerful, it introduces a small amount of overhead. For extremely large forms (hundreds of fields), the process of parsing the entire schema on every change can be more resource-intensive than simple string-based rules.
Additionally, Zod's strict typing can occasionally clash with HTML input values, which are natively strings. As seen in the age example, you must be explicit about type conversion (e.g., using v-model.number or Zod's z.coerce.number()) to avoid "invalid type" errors when the form receives a string from an input field.
Verification and Testing
To verify the implementation is working as expected, perform these three checks:
- Trigger Blur: Click into a field and click out without typing. The
touchedstate should update, and the error message should appear only after the field has been visited. - Type Check: Hover over the
valuesargument in theonSubmitfunction. It should show the inferredProfileFormtype rather thanany. - Invalid Submission: Attempt to submit the form with empty fields. The
handleSubmitwrapper should prevent theonSubmitcallback from executing and populate theerrorsobject.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.