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.

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.

← Back to Vue VeeValidate Validation