errors
A reactive Proxy keyed by schema paths. Every leaf carries its current error list, container reads aggregate everything underneath, and the whole tree re-renders the moment validation re-runs.
- Category
- Return property
- Type
ErrorsProxyShape<Form>- Reactive
- Yes
form.errors is the raw validation surface, paired one-to-one with form.values and form.fields. The demo seeds three invalid values up front so the panels light up on mount, then updates live as you edit each field. The container panel shows the live profile sub-tree; the whole-form panel shows the full sparse tree.
Leaf reads
form.errors.name[0]?.message- "Name is required"
form.errors.email[0]?.message- "Enter a valid email"
form.errors.profile.bio[0]?.message- "Keep it under 50 chars"
form.errors.profile[''][0]?.message- "Bio must mention your handle."
Container read
form.errors.profile materialises the live sub-tree. Container-self errors (the refine on profile) sit at the '' sentinel slot alongside descendant leaves.
{
"bio": [
{
"message": "Keep it under 50 chars",
"path": [
"profile",
"bio"
],
"formKey": "docs-demo-errors",
"code": "zod:too_big"
}
],
"": [
{
"message": "Bio must mention your handle.",
"path": [
"profile"
],
"formKey": "docs-demo-errors",
"code": "zod:custom"
}
]
}Whole form
form.errors materialises the full sparse tree of errors across the schema.
{
"name": [
{
"message": "Name is required",
"path": [
"name"
],
"formKey": "docs-demo-errors",
"code": "zod:too_small"
}
],
"email": [
{
"message": "Enter a valid email",
"path": [
"email"
],
"formKey": "docs-demo-errors",
"code": "zod:invalid_format"
}
],
"profile": {
"bio": [
{
"message": "Keep it under 50 chars",
"path": [
"profile",
"bio"
],
"formKey": "docs-demo-errors",
"code": "zod:too_big"
}
],
"": [
{
"message": "Bio must mention your handle.",
"path": [
"profile"
],
"formKey": "docs-demo-errors",
"code": "zod:custom"
}
]
}
}Leaf reads
const schema = z.object({
email: z.email('Enter a valid email'),
name: z.string().min(1, 'Name is required'),
})
const form = useForm({ schema })
form.errors.email // readonly ValidationError[]
form.errors.email[0]?.message // 'Enter a valid email' | undefined
form.errors.email.length // 0 when valid
A static object leaf always returns an array, readonly ValidationError[], empty when valid. Reads past a dynamic boundary carry | undefined, because the node there may be absent: a numeric array index (form.errors.todos[3]?.title), a record key (form.errors.byId.missing), or a field that only exists on an inactive discriminated-union variant. That | undefined is honest. Dot and index access is pure navigation, so a key the schema doesn't declare and the data doesn't hold reads undefined, never a stand-in proxy, and a truthy check agrees with the runtime. The one exception is a server error parked at a non-schema key: the error stores count as holding it, so it stays reachable and its message lands on the '' container-self sentinel (form.errors.ghost['']).
The first error's .message is what most templates render:
<template>
<input v-register="form.register('email')" />
<p v-if="form.errors.email.length">{{ form.errors.email[0]?.message }}</p>
</template>
For display ergonomics, gating by getDisplayState and pulling the first error in one shot, reach for form.fields.email.firstError paired with form.fields.email.showErrors. The errors Proxy is the raw aggregate; the fields Proxy is the same data with display gating and firstError sugar layered on.
Container reads
form.errors is a drillable Proxy: dot-access descends into containers (returning a sub-Proxy you can keep drilling), and the call form returns a flat aggregate at any path. The two surfaces serve different jobs:
form.errors.profile // sub-Proxy: { '': [...refines], bio: [...], ... }
form.errors('profile') // flat array: every error inside profile + container-self
form.errors() // flat array: every error in the form
form.errors() is the cheapest "is anything wrong?" check (form.errors().length === 0 when the form is valid). For aggregated counts and submission-state bits, see form.meta. When you serialize the dot-form (JSON.stringify(form.errors) or {{ form.errors }} in a template), the Proxy materializes the live sparse tree, so you can dump the whole error state for debugging without losing structure.
The '' sentinel: container-self errors
A cross-field .refine() lives on a container, not a leaf:
const schema = z.object({
profile: z
.object({
bio: z.string().max(50),
handle: z.string(),
})
.refine((p) => p.bio.includes(p.handle), 'Bio must mention your handle'),
})
The refine's error path is ['profile'], the container itself. To keep form.errors.profile readable alongside leaf errors at ['profile', 'bio'] and ['profile', 'handle'], container-self errors land in the materialized tree under the '' sentinel slot:
form.errors.profile[''] // refine errors on profile (and any other container-self entries)
form.errors.profile.bio // leaf errors on bio
form.errors[''] // root form-level errors (setFormErrors, root refines)
JSON.stringify(form.errors.profile) materializes as { '': [refineError], bio: [maxError], handle: [...] }. Both the refine and the descendant leaves coexist; nothing clobbers anything. The same convention reaches all the way down: a refine on profile.address lands at form.errors.profile.address[''].
The call form is the flat alternative: form.errors('profile') returns one ValidationError[] containing the refine PLUS every descendant leaf error in declaration order, no structure. Reach for the structural tree when you want to render per-field; reach for the call form when you want "anything wrong under this container?".
If your schema legitimately declares a field literally named '' (an exceptionally rare choice), the literal leaf's own errors and any container-self errors share the slot. Both arrays concatenate into a single read.
Setting errors imperatively
Server-side errors land in the same reactive store as Zod errors:
form.setFieldErrors([
{ path: 'email', message: 'Already taken' },
{ path: 'profile.handle', message: 'Reserved' },
])
form.errors.email and form.errors.profile.handle update immediately, and any form.fields.<path>.firstError / form.fields.<path>.showErrors reads update with them. Pair with parseApiErrors to convert a server response payload into the { path, message } shape setFieldErrors expects; the render surface is identical whether the error came from Zod or your API.
Where to next
- The form: every other reactive read.
values: the read companion to errors.fields: per-leaf state, including the gatedfirstError/showErrorspairing.meta: the form-level aggregates (errorCount,valid,submitting, etc.).- When validation runs: the moment errors appear.
- Server-side errors:
setFieldErrors+parseApiErrorsin full.