Form Validation Patterns in Vue and TypeScript
Implement robust form validation in Vue with TypeScript — schema-based validation with Zod, field-level and form-level patterns, and accessible error handling.
Strategic Systems Architect & Enterprise Software Developer
Form validation is one of those areas where most applications start simple and end up messy. A few v-if checks on individual fields, some regex patterns copied from Stack Overflow, error messages that appear at random times — it works until it does not. The maintainability problems compound as forms grow, and the user experience suffers from inconsistent feedback.
The solution is not more validation code. It is better architecture for validation. Schema-based validation with a library like Zod, combined with a form management layer like VeeValidate, gives you consistent validation logic that runs on both client and server with zero duplication.
Schema-Based Validation With Zod
Zod lets you define your validation rules as a schema that doubles as a TypeScript type. This is the core insight that makes the approach work — you define the shape of valid data once, and you get both runtime validation and compile-time type checking from the same source.
import { z } from 'zod'
Const contactFormSchema = z.object({
name: z.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name is too long'),
email: z.string()
.email('Please enter a valid email address'),
company: z.string().optional(),
message: z.string()
.min(10, 'Please provide more detail')
.max(2000, 'Message is too long'),
})
Type ContactForm = z.infer<typeof contactFormSchema>
The ContactForm type is derived from the schema. If you add a field to the schema, the type updates automatically. If you make a field required, TypeScript catches every place that does not provide it. This eliminates the entire category of bugs where validation rules and type definitions drift apart.
Zod schemas compose naturally. If your registration form extends your login form with additional fields, you use z.extend() or z.merge() rather than duplicating the email and password validation. This composition is especially useful when the same validation runs on the server API routes — you import the schema and validate the request body with the same rules the frontend uses.
VeeValidate Integration
VeeValidate is the most mature form library in the Vue ecosystem, and its Zod integration is first-class. The useForm composable handles form state, validation timing, submission, and error tracking:
<script setup lang="ts">
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { contactFormSchema } from '~/schemas/contact'
Const { handleSubmit, errors, isSubmitting } = useForm({
validationSchema: toTypedSchema(contactFormSchema),
initialValues: {
name: '',
email: '',
message: '',
},
})
Const onSubmit = handleSubmit(async (values) => {
// values is typed as ContactForm — no casting needed
await $fetch('/api/contact', { method: 'POST', body: values })
})
</script>
The toTypedSchema wrapper bridges Zod and VeeValidate. The handleSubmit function only calls your callback if validation passes, and values is fully typed based on the schema. No manual type assertions, no as unknown as ContactForm anywhere.
For field-level binding, VeeValidate's useField composable connects individual inputs to the form context:
<script setup lang="ts">
const { value: name, errorMessage: nameError } = useField<string>('name')
</script>
<template>
<div>
<label for="name">Name</label>
<input id="name" v-model="name" :aria-describedby="nameError ? 'name-error' : undefined" />
<p v-if="nameError" id="name-error" role="alert" class="text-error-500 text-sm mt-1">
{{ nameError }}
</p>
</div>
</template>
Notice the accessibility details: the label is associated with the input via for/id, the error message has role="alert" for screen reader announcements, and aria-describedby links the input to its error. These are not optional for production forms.
Validation Timing and UX
When validation runs matters as much as what it validates. Validating on every keystroke is aggressive and distracting — the user sees "Email is invalid" before they have finished typing their address. Validating only on submit means the user fills out an entire form before learning about errors.
The best pattern is validate on blur for initial feedback, then validate on change after an error is shown. VeeValidate supports this through the validateOnBlur, validateOnChange, and validateOnInput options. The default behavior is close to ideal, but you should test it with real users.
const { handleSubmit } = useForm({
validationSchema: toTypedSchema(contactFormSchema),
validateOnMount: false, // Don't show errors before interaction
})
For multi-step forms, validate each step independently before allowing progression. Do not wait until the final submit to reveal that step one has errors — the user has to navigate back and re-enter context they have already left behind. This is a UX failure I see frequently in forms that were built step by step without considering the overall flow.
Cross-field validation — where one field's validity depends on another — is handled through Zod's .refine() method. A common example is password confirmation:
const registrationSchema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
The path option tells VeeValidate which field should display the error. Without it, the error attaches to the form level rather than the specific field.
Server-Side Validation and Error Mapping
Client-side validation is a UX convenience, not a security boundary. The server must validate everything independently. The advantage of Zod schemas is that the same schema runs in both environments:
// server/api/contact.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const result = contactFormSchema.safeParse(body)
if (!result.success) {
throw createError({
statusCode: 422,
data: result.error.flatten(),
})
}
// Process valid data
})
When the server returns validation errors, map them back to the form fields. VeeValidate's setErrors function accepts a record of field names to error messages, which matches Zod's flattened error format. The user sees the same error presentation regardless of whether validation ran on the client or server.
This architecture — shared Zod schemas, VeeValidate for form state, accessible error display, server-side validation as the source of truth — handles everything from simple contact forms to complex multi-step workflows. The initial setup takes longer than ad-hoc validation, but it scales indefinitely and maintains itself through the type system. For more on building accessible form interfaces, that article covers the UX patterns that complement these technical patterns.