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 values object to be fully typed end-to-end, not loosely typed Record<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.

Zod schema to typed VeeValidate flow A single Zod object schema passes through toTypedSchema and fans out into an inferred values type, a typed validation schema for useForm, and a keyed error bag. Zod schema z.object({…}) toTypedSchema() adapter z.infer → values type useForm schema errors keyed by field
One schema, three typed outputs: the inferred value shape, the validation schema VeeValidate runs, and the keyed error bag.

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.

← Back to Vue VeeValidate Validation