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.
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.
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