VeeValidate Form-Level Schema Validation
Attaching a single validation schema to a whole <Form> — rather than wiring rules field by field — lets VeeValidate resolve every value in one pass and gives you a coherent errors object to drive submission, while still deferring the final gate to the native Constraint Validation API. This page shows how to define that form-level schema, keep the browser as the source of truth on submit, and surface the results accessibly.
When to Use This Form-Level Schema Approach
Reach for a form-level schema when the shape of your data is the unit of truth: multiple fields whose validity is interdependent, or a payload you already model as an object elsewhere. It differs from the field-by-field rule binding covered in the parent Vue VeeValidate Validation guide, and it is the natural precursor to fully typed schema validation with VeeValidate and Zod.
- Prefer form-level when rules span several fields — cross-field validation like password confirmation is far cleaner in one schema.
- Prefer per-field rules when fields are independent and you want the lightest possible wiring.
- Choose this when a single object schema already describes your DTO and you want one validator, not many.
reportValidity() call still decides whether the form submits. Defining a validationSchema on <Form> with reportValidity()
The baseline below keeps <form novalidate> semantics, resolves the whole object through one schema function, pushes messages into each control via setCustomValidity(), and lets a single reportValidity() call gate submission. It is copy-paste runnable in any Vue 3 + VeeValidate 4 project.
import { defineComponent, h, ref } from "vue";
import { Form, Field } from "vee-validate";
// A form-level schema: receives ALL values, returns an errors object
// keyed by field name. Empty/undefined entry means that field is valid.
type SignupValues = { email: string; password: string; confirm: string };
function schema(values: Partial<SignupValues>): Record<string, string> {
const errors: Record<string, string> = {};
if (!values.email) errors.email = "Email is required.";
else if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(values.email))
errors.email = "Enter a valid email address.";
if (!values.password || values.password.length < 8)
errors.password = "Password must be at least 8 characters.";
// cross-field rule lives naturally in the single schema
if (values.confirm !== values.password)
errors.confirm = "Passwords do not match.";
return errors;
}
export default defineComponent({
setup() {
const formEl = ref<HTMLFormElement | null>(null);
// VeeValidate hands us validated errors; we mirror them onto the
// native controls so the Constraint Validation API stays authoritative.
function onInvalidSubmit({ errors }: { errors: Record<string, string> }) {
const form = formEl.value;
if (!form) return;
for (const el of Array.from(form.elements) as HTMLInputElement[]) {
if (!el.name) continue;
el.setCustomValidity(errors[el.name] ?? "");
}
form.reportValidity(); // single native gate — focuses first invalid field
}
function onSubmit(values: SignupValues) {
// Reached only when the schema returned no errors.
console.log("submitting", values);
}
return () =>
h(
Form,
{
// The whole-form validator. VeeValidate calls it with every value.
validationSchema: schema,
onSubmit,
onInvalidSubmit,
// render a real <form novalidate> so the browser never pre-empts us
as: "form",
novalidate: true,
ref: formEl,
},
() => [
h(Field, { name: "email", as: "input", type: "email" }),
h(Field, { name: "password", as: "input", type: "password" }),
h(Field, { name: "confirm", as: "input", type: "password" }),
h("button", { type: "submit" }, "Create account"),
],
);
},
});
Because onSubmit only fires when the schema is clean, and onInvalidSubmit mirrors messages into setCustomValidity() before calling reportValidity(), the native browser UI remains the single source of truth for the final gate — exactly the baseline this site recommends.
<Form> Schema: Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
validationSchema |
object | (values) => errors | Promise |
undefined |
The whole-form validator; may be a spec object, a resolver function, or a typed schema. |
initialValues |
Record<string, unknown> |
{} |
Seeds values before the first resolve, so schema-computed defaults are honoured. |
validateOnMount |
boolean |
false |
Runs the schema once on mount; keep false to avoid errors before interaction. |
keepValuesOnUnmount |
boolean |
false |
Retains a field’s value when its <Field> unmounts (wizard steps). |
onSubmit |
(values, ctx) => void |
— | Fires only when the schema resolves with no errors. |
onInvalidSubmit |
({ values, errors }) => void |
— | Fires when the schema reports errors; the hook for native mirroring. |
Verification Steps
import { test, expect } from "@playwright/test";
test("form-level schema gates submit natively", async ({ page }) => {
await page.goto("/signup");
await page.getByRole("button", { name: "Create account" }).click();
// The native constraint message comes straight from the schema.
const msg = await page
.locator('input[name="email"]')
.evaluate((el: HTMLInputElement) => el.validationMessage);
expect(msg).toBe("Email is required.");
});
Edge Cases & Failure Modes
Stale setCustomValidity() blocks a corrected field. Once you set a non-empty message, the control stays invalid until you clear it. Always write el.setCustomValidity(errors[el.name] ?? "") for every named element on each submit — passing an empty string resets fields the user has since fixed.
Async rules resolving after the native gate. If your schema performs asynchronous server checks, reportValidity() may run against a resolved-but-stale result. Await the schema promise inside onInvalidSubmit before mirroring, and debounce so a slow uniqueness check does not fight the submit.
Messages that never reach the accessibility tree. Native bubbles are transient and not announced consistently, so pair them with accessible error messaging using aria-describedby, and verify with axe-core accessibility testing. Also handle focus management after a failed submit so keyboard users land on the offending control.
Resolving Nested and Array Field Paths
A form-level schema rarely stays flat. VeeValidate flattens the errors object using dotted and bracketed paths — address.city, phones[0], phones[1] — and expects the matching <Field name="address.city"> to consume each one. That convention matters the moment you mirror onto native controls, because form.elements[name] is keyed by the input’s name attribute, not by VeeValidate’s internal path. Keep the two aligned by giving every input the exact dotted name the schema emits.
The resolver below validates an object with a nested group and a repeatable array, then returns a fully flattened errors map. Because the keys are literal path strings, the same onInvalidSubmit loop from the baseline mirrors them without changes.
import type { Field } from "vee-validate";
interface Contact {
name: string;
address: { city: string; postcode: string };
phones: string[];
}
// Returns a flat map whose keys are the dotted/bracketed field paths
// VeeValidate uses internally — and that native inputs carry as `name`.
function contactSchema(values: Partial<Contact>): Record<string, string> {
const errors: Record<string, string> = {};
if (!values.name?.trim()) errors["name"] = "Name is required.";
// Nested group: one dotted key per leaf, never a nested object.
if (!values.address?.city) errors["address.city"] = "City is required.";
if (!/^\d{4,5}$/.test(values.address?.postcode ?? ""))
errors["address.postcode"] = "Postcode must be 4–5 digits.";
// Array fields: index each entry so the message lands on the right row.
(values.phones ?? []).forEach((phone, i) => {
if (!/^\+?[\d\s-]{7,}$/.test(phone))
errors[`phones[${i}]`] = "Enter a valid phone number.";
});
return errors;
}
The subtlety is that an empty array produces no keys at all, so a form that requires at least one phone number needs a guard on the container itself — attach that message to a synthetic key such as phones and render it in a region rather than on any single input, since there is no phones[0] control to focus. This is the same distinction between item-level and collection-level errors you meet in dynamic field array validation.
Driving a Programmatic Submit with validate() and meta.valid
Not every submit comes from a click. Wizards, autosave, and “save draft” buttons need to run the whole-form schema on demand and read the result without triggering onSubmit. Expose the slot props from <Form> and call validate() yourself; it resolves the schema over the current values and returns { valid, errors } without side effects on your submit handler.
import { Form } from "vee-validate";
// Inside a <Form v-slot="{ validate, values, meta }"> ... </Form>
async function saveDraft(
validate: () => Promise<{ valid: boolean; errors: Record<string, string> }>,
meta: { valid: boolean; dirty: boolean },
) {
// `meta.valid` reflects the LAST resolve — cheap to read but possibly stale.
if (!meta.dirty) return; // nothing changed since the previous save
const { valid, errors } = await validate(); // force a fresh resolve
if (valid) {
await persistDraft();
return;
}
// Surface only the fields the user has actually touched, so an
// untouched draft does not light up every control at once.
reportPartial(errors);
}
Read meta.valid for a cheap, reactive flag that disables a button; call validate() when you need an authoritative, up-to-date answer. The two differ by exactly one resolve: meta.valid describes the previous pass, while validate() guarantees the current values were checked. Confusing them is the most common cause of a form that submits one keystroke behind the user’s last edit.
Frequently Asked Questions
Can a VeeValidate form-level schema be a plain function instead of a library object?
Yes. validationSchema accepts a function that receives all values and returns an errors object (or a promise of one). This is the lightest way to express whole-form and cross-field rules without adding a validation library, though a typed schema gives you inference for free.
How do I map schema errors onto native inputs instead of VeeValidate's own message slots?
In onInvalidSubmit, iterate form.elements and call el.setCustomValidity(errors[el.name] ?? "") on each named control, then call reportValidity() once. See custom validity messages for the message-lifecycle details.
Should I use a form-level schema or per-field rules for a simple two-field form?
If the two fields are independent, per-field rules are less code. The moment one field's validity depends on another — a confirmation, a date range — the single schema wins because it sees every value at once and returns one coherent errors object.
Why does my nested field error never appear on the input?
Almost always a key mismatch. VeeValidate flattens nested paths to dotted keys like address.city, so the schema must return that exact string and the control must carry name="address.city". A returned nested object ({ address: { city: "…" } }) will not resolve — flatten it, and mirror it onto the input whose name matches the path verbatim.
How do I trigger the whole-form schema without submitting?
Call the validate() slot prop exposed by <Form>. It resolves the schema over the current values and returns { valid, errors } without invoking onSubmit or onInvalidSubmit, which is exactly what a "save draft" or wizard "next" button needs. Prefer it over reading meta.valid when you need a guaranteed-fresh answer rather than the result of the previous resolve.
Related Guides
- Vue VeeValidate Validation — the parent guide covering per-field rules and setup.
- Typed Schema Validation with VeeValidate and Zod — upgrade the schema to a typed resolver using Zod schema validation.
- Integrating the Zod Resolver with React Hook Form — the equivalent form-level pattern in React.
- Cross-Field Validation Strategies — the interdependent-field rules a form-level schema handles best.