Typed Schema Validation with VeeValidate and Zod
This recipe shows how to drive a VeeValidate 4 form from a single Zod schema using toTypedSchema, so field values, error keys, and the submission payload all share one inferred TypeScript type. The result is a form where adding a field to the schema automatically types the value and surfaces a compile error if the template forgets it.
When to Use This Recipe
Reach for toTypedSchema(zodSchema) when you already model your domain with Zod (or want to share the same schema between client and server) and you want VeeValidate’s reactive field state and submission gating layered on top. It is the right choice when:
- You need the submitted
valuesobject to be fully typed end-to-end, not loosely typedRecord<string, unknown>. - Your validation rules include cross-field constraints or transforms that are awkward to express as standalone field rules.
- You want one schema to serve both the form and a server-side parse, keeping client and backend in lockstep.
If you only have a handful of independent fields with simple rules, the inline useField rules shown in Vue VeeValidate Validation are lighter weight. Reach for a typed schema when the shape matters as much as the rules.
Minimal Working Implementation
Install the adapter alongside VeeValidate and Zod, then wrap your schema once.
// signup-form.ts
import { useForm } from 'vee-validate';
import { toTypedSchema } from '@vee-validate/zod';
import { z } from 'zod';
// 1. Author the schema. z.coerce handles native string inputs for numbers.
const signupSchema = z.object({
email: z.string().min(1, 'Email is required').email('Enter a valid email'),
password: z.string().min(8, 'At least 8 characters'),
acceptsTerms: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms' }),
}),
});
// 2. Derive the form value type from the schema — the single source of truth.
export type SignupValues = z.infer<typeof signupSchema>;
// 3. Wrap once. toTypedSchema makes useForm infer SignupValues automatically.
const validationSchema = toTypedSchema(signupSchema);
export function useSignupForm() {
const { handleSubmit, errors, defineField, setFieldError, meta } = useForm({
validationSchema,
});
// defineField returns a typed [model, attrs] tuple per key
const [email, emailAttrs] = defineField('email');
const [password, passwordAttrs] = defineField('password');
const [acceptsTerms, termsAttrs] = defineField('acceptsTerms');
// values passed to the callback are typed as SignupValues — no casting
const onSubmit = handleSubmit(async (values) => {
try {
await registerUser(values); // values: SignupValues
} catch (err) {
if (err instanceof ConflictError) {
// Map a server error back onto a typed field key
setFieldError('email', 'That email is already registered');
}
}
});
return {
onSubmit,
errors, // Partial<Record<keyof SignupValues, string>>
meta,
fields: { email, emailAttrs, password, passwordAttrs, acceptsTerms, termsAttrs },
};
}
The errors object is keyed by the schema’s field names, so errors.email is type-checked — a typo like errors.emial is a compile error. This is the typed-error-mapping payoff: the error channel and the value channel share one shape derived from z.infer. Each message is written to state the fix, satisfying WCAG SC 3.3.3 (Error Suggestion) the same way a well-crafted custom validity message would for native validation.
<!-- SignupForm.vue -->
<script setup lang="ts">
import { useSignupForm } from './signup-form';
const { onSubmit, errors, fields } = useSignupForm();
</script>
<template>
<form novalidate @submit="onSubmit">
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
type="email"
v-model="fields.email"
v-bind="fields.emailAttrs"
:aria-invalid="!!errors.email"
:aria-describedby="errors.email ? 'email-error' : undefined"
/>
<p v-if="errors.email" id="email-error" class="error" role="alert">{{ errors.email }}</p>
</div>
<button type="submit">Sign Up</button>
</form>
</template>
Cross-Field Rules Through the Same Schema
Because the entire object passes through one Zod schema, cross-field rules live in the schema too — no separate group validator. A .refine() (or .superRefine() for multiple errors) compares fields and attaches the message to a specific path so VeeValidate routes it to the right errors key.
const changePasswordSchema = z
.object({
password: z.string().min(8, 'At least 8 characters'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'], // attaches the error to confirmPassword, not the root
});
const validationSchema = toTypedSchema(changePasswordSchema);
With path: ['confirmPassword'], the mismatch surfaces as errors.confirmPassword, so the existing template binding renders it under the confirm field with no extra wiring. This keeps the relationship rule in the same typed source of truth as the field rules — the schema-first analog of the group-level validator pattern used in reactive forms, and an alternative to the manual setCustomValidity comparison the native API requires.
Option Reference
| Option / symbol | Type | Purpose |
|---|---|---|
toTypedSchema(schema) |
(ZodSchema) => TypedSchema |
Adapts a Zod schema; enables value/error inference |
z.infer<typeof schema> |
type | The inferred form values type |
z.coerce.number() |
schema | Parses native string inputs before numeric checks |
z.literal(true) |
schema | Enforces a required checkbox |
defineField(name) |
[Ref, attrs] |
Typed model + validation triggers per field |
errors[key] |
string | undefined |
Typed, keyed first message per field |
setFieldError(key, msg) |
function | Inject a server error onto a typed key |
Verification Steps
Confirm the type wiring with a deliberate mismatch and a Playwright check.
// In your editor, this line MUST be a compile error if inference works:
// onSubmit(values) => values.unknownField // ❌ Property does not exist
// Playwright: assert the error is announced and associated
import { test, expect } from '@playwright/test';
test('email error is associated and announced', async ({ page }) => {
await page.goto('/signup');
await page.getByLabel('Email').fill('not-an-email');
await page.getByRole('button', { name: 'Sign Up' }).click();
const error = page.locator('#email-error');
await expect(error).toHaveText('Enter a valid email');
await expect(error).toHaveAttribute('role', 'alert');
await expect(page.getByLabel('Email')).toHaveAttribute('aria-invalid', 'true');
});
In Vue DevTools, inspect the form component and confirm errors only contains keys present in your schema — extra keys indicate a manual setFieldError typo that bypassed inference.
Edge Cases and Failure Modes
1. z.number() rejects every value. Native number inputs emit strings. Use z.coerce.number():
// Before — "30" fails the type check
age: z.number().min(18)
// After — coerced to 30 before min runs
age: z.coerce.number().min(18)
2. Optional fields read as undefined vs empty string. A blank text input is "", not undefined, so z.string().optional() still receives "". Use .or(z.literal('')) or .transform() to normalize before the rule.
3. Server errors overwritten on next keystroke. setFieldError writes a transient message; VeeValidate re-runs the schema on the next input and clears it. That is usually correct, but for a persistent uniqueness error, re-check on the server via an asynchronous server check rather than relying on the one-shot setFieldError.
Typing Initial Values Without Fighting the Inference
A common friction point is initialValues. Because z.infer reports the output type of the schema, a field authored with z.coerce.number() infers as number, yet the DOM hands you a string on first paint. If you seed initialValues with the raw string, TypeScript complains that string is not assignable to number. The clean fix is to let the input type and the parsed type diverge deliberately, using z.input for the seed and z.output for everything downstream.
// Two distinct types fall out of one schema.
type SignupInput = z.input<typeof signupSchema>; // pre-coercion shape
type SignupValues = z.output<typeof signupSchema>; // post-parse shape (= z.infer)
const { handleSubmit, errors, defineField } = useForm<SignupValues>({
validationSchema,
// The seed matches the schema's INPUT type, so empty strings are legal.
initialValues: {
email: '',
password: '',
acceptsTerms: false,
} as Partial<SignupInput>,
});
Passing the explicit useForm<SignupValues>() generic pins the output type even when a broad initialValues object would otherwise widen it. Keep the two aliases side by side in the module — most bugs here come from a seed silently typed against the output shape, which forces you to lie to the compiler with 0 or NaN placeholders that then flow into your first render.
Nested Objects and Array Fields via Dotted Paths
defineField accepts a dot-and-bracket path string, so a nested Zod object maps to individual bound models without flattening the schema. The path is checked against the inferred shape, meaning defineField('address.postcode') compiles only if address.postcode actually exists.
const profileSchema = z.object({
name: z.string().min(1, 'Name is required'),
address: z.object({
postcode: z.string().regex(/^\d{4}$/, 'Four digits'),
country: z.string().min(2, 'Select a country'),
}),
tags: z.array(z.string().min(1)).min(1, 'Add at least one tag'),
});
const { defineField, errors } = useForm({
validationSchema: toTypedSchema(profileSchema),
});
// Nested and indexed paths are both type-checked against z.infer.
const [postcode, postcodeAttrs] = defineField('address.postcode');
const [firstTag, firstTagAttrs] = defineField('tags.0');
Errors follow the same convention: a failed nested rule surfaces under the flattened key, so errors['address.postcode'] holds the message. For array fields prefer VeeValidate’s useFieldArray when the row count is dynamic — it keeps stable keys across insert and remove, which a raw index path cannot. Reserve dotted index paths for fixed-length tuples where the position is meaningful.
Controlling When the Schema Runs
Running a full schema.parse on every keystroke is wasteful for large objects with expensive .refine() predicates. VeeValidate gates this through validateOnValueUpdate and the per-field validateOnInput / validateOnBlur attrs that defineField returns. Set validation to fire on blur for costly cross-field refinements, then let the guaranteed pre-submit pass inside handleSubmit catch anything the user skipped — handleSubmit never invokes its callback unless the whole schema passes, so lazy per-field timing never lets an invalid payload through.
Frequently Asked Questions
Can I reuse the same Zod schema on the server?
Yes — that is the main advantage. Keep the schema in a shared module, pass it through toTypedSchema on the client and call schema.parse(body) on the server. Both ends enforce identical rules and infer the same type, eliminating client/server drift.
Does toTypedSchema work with Yup too?
Yes. Import toTypedSchema from @vee-validate/yup instead of @vee-validate/zod. The VeeValidate-facing API is identical; only the schema authoring syntax differs.
How do typed errors map to accessible markup?
Each errors[key] is a string you bind to a node with role="alert" and associate via :aria-describedby, toggling :aria-invalid on the input. This is the same association the native Constraint Validation API exposes through validationMessage, satisfying WCAG SC 3.3.1.
Why does errors only show one message per field?
VeeValidate's errors bag is intentionally the single first message per key, which keeps the accessible role="alert" region concise. If you need every failing rule for a field, read useForm().errorBag instead, which exposes a string[] per key. Zod evaluates rules in author order, so the first message is the first constraint you declared.
How do I handle a discriminated union schema?
Wrap a z.discriminatedUnion('kind', [...]) in toTypedSchema as usual. The inferred values type narrows on the discriminant, so inside handleSubmit a switch (values.kind) gives you full member typing per branch. Bind the discriminant field with defineField('kind') and let conditional template blocks show only the members relevant to the current selection.
Related Guides
- Vue VeeValidate Validation — the full Composition API and component reference
- Schema-Based Validation with Zod — author and compose the schemas this recipe consumes
- Using Zod for Complex Form Schemas — nested objects, unions, and refinements
- Constraint Validation API Deep Dive — the native validity model the typed errors mirror