Mapping Server Field Errors to Form Inputs
Your API returns { "address.city": ["Enter a town or city."], "items.2.qty": ["Only 3 left in stock."], "delivery": ["Choose a delivery option."] } — how do you put each message on the right input, including the third row of a repeating table and a group of radio buttons, clear each one when the user edits it, catch the errors that have no matching input, and move focus to the first problem in document order? This recipe resolves server paths to form controls with form.elements.namedItem(), applies messages through setCustomValidity() so the Constraint Validation API delivers focus and announcements, and handles the special cases that generic snippets miss.
When You Need a Robust Error Mapper
Any form whose server can reject values the browser accepted needs this — which is every form with uniqueness checks, stock checks, permission checks or business rules. A simple document.getElementById(key) works on toy forms; a real mapper is needed when:
- Field names are nested or indexed (
address.city,items.2.qty), as produced by validating nested object fields and repeating rows. - Controls come in groups — radio buttons, checkbox sets — where the name matches several elements.
- The server returns errors for fields the form does not render, such as a hidden account ID or a field removed in a newer UI.
The response format this recipe consumes is described in problem details (RFC 9457) for field errors; a flat { path: messages[] } object works identically after converting pointers to dotted paths.
Minimal Working Error Mapper
type ServerErrors = Record<string, string[]>;
type Control = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
const isControl = (el: unknown): el is Control =>
el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement;
/** Find the control that should carry the error for a given field name. */
function resolveTarget(form: HTMLFormElement, name: string): Control | null {
const found = form.elements.namedItem(name);
if (!found) return null;
if (found instanceof RadioNodeList) {
// Radio group or repeated checkboxes: the first enabled member carries the message.
return ([...found].find((n) => isControl(n) && !n.disabled) as Control) ?? null;
}
if (!isControl(found)) return null;
if (found.type === "hidden") {
// Hidden inputs can't be focused or show errors; use a declared visible proxy instead.
const proxy = found.dataset.errorTarget && form.querySelector<Control>(found.dataset.errorTarget);
return proxy ?? null;
}
return found;
}
export function applyServerErrors(form: HTMLFormElement, errors: ServerErrors): { unmatched: string[] } {
// 1. Clear any previous server errors so stale messages never survive a new response.
for (const el of form.querySelectorAll<Control>("[data-server-error]")) {
el.setCustomValidity("");
el.removeAttribute("data-server-error");
el.removeAttribute("aria-invalid");
}
const unmatched: string[] = [];
const applied: Control[] = [];
for (const [name, messages] of Object.entries(errors)) {
const message = messages.join(" ");
const target = resolveTarget(form, name);
if (!target) {
unmatched.push(message);
continue;
}
target.setCustomValidity(message);
target.setAttribute("aria-invalid", "true");
target.dataset.serverError = name;
const out = document.getElementById(`${target.id}-err`);
if (out) { out.textContent = message; out.hidden = false; }
applied.push(target);
// 2. Clear on the user's next edit of this control (or any member of its group).
const clear = () => {
target.setCustomValidity("");
target.removeAttribute("aria-invalid");
delete target.dataset.serverError;
if (out) out.hidden = true;
};
const group = form.elements.namedItem(name);
const members = group instanceof RadioNodeList ? [...group] : [target];
members.forEach((m) => m.addEventListener("input", clear, { once: true }));
}
// 3. Form-level summary for anything we could not place.
const summary = form.querySelector<HTMLElement>("[data-form-errors]");
if (summary) {
summary.textContent = unmatched.join(" ");
summary.hidden = unmatched.length === 0;
}
// 4. Let the browser focus the FIRST invalid control in document order and announce it.
if (applied.length) form.reportValidity();
else if (unmatched.length) summary?.focus();
return { unmatched };
}
reportValidity() walks controls in document order, so focus lands on the first failing field on the page — not the first key in the JSON object, which follows whatever order the server happened to produce. That ordering matters for WCAG 2.4.3 (Focus Order): the user starts fixing from the top, as they would read.
Error Mapper Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
| Name resolution | form.elements.namedItem |
on | Handles dotted and indexed names verbatim |
| Group target | first enabled member | on | Radio groups and checkbox sets carry one message |
data-error-target |
CSS selector | none | Visible proxy for hidden inputs |
| Stale clearing | [data-server-error] sweep |
on | Removes previous server errors before applying new ones |
| Clear-on-edit | one-time input listener |
on | Server errors never outlive the value they described |
| Multiple messages | joined with a space | on | One custom validity string per control |
| Unmatched | form-level summary | on | Errors without a control are still shown |
Verification Steps
import { test, expect } from "@playwright/test";
test("indexed and grouped server errors land on the right controls", async ({ page }) => {
await page.route("**/api/checkout", (r) => r.fulfill({
status: 422, contentType: "application/json",
body: JSON.stringify({ errors: { "items.2.qty": ["Only 3 left in stock."], delivery: ["Choose a delivery option."], card: ["Your saved card has expired."] } }),
}));
await page.goto("/checkout");
await page.getByRole("button", { name: "Place order" }).click();
const qty = page.locator('[name="items.2.qty"]');
await expect(qty).toHaveAttribute("aria-invalid", "true");
await expect(page.locator("[data-form-errors]")).toHaveText("Your saved card has expired.");
await page.getByLabel("Express delivery").check();
expect(await page.getByLabel("Standard delivery").evaluate((el: HTMLInputElement) => el.validity.valid)).toBe(true);
});
Edge Cases and Failure Modes
Names with brackets. Some back-ends use items[2][qty]. namedItem needs the exact attribute value, so either keep bracket names in both places or convert consistently; mixing items.2.qty on the server with items[2][qty] in the HTML means every indexed error ends up unmatched.
Rows that no longer exist. The user removed row 3 while the request was in flight; the error for items.2.qty now points at what used to be row 4, or at nothing. Include a stable row ID in the name (items.a91f.qty) rather than an index when rows can be reordered or removed during submission.
Disabled fields. Disabled controls are barred from constraint validation, so setCustomValidity on them has no visible effect and reportValidity skips them. Treat an error on a disabled field as form-level, or enable the field when it is the one the user must fix.
Framework-controlled inputs. In React, Vue or Angular, the framework may re-render and overwrite attributes you set. Apply server errors through the framework’s form state — setError in React Hook Form, setFieldError in VeeValidate, setErrors on an Angular control — and let the framework set aria-invalid; keep setCustomValidity for native reporting. The library-specific APIs are covered in framework integration patterns.
Keeping Names and Paths in Agreement
The mapper depends on one invariant: the path the server reports equals the name attribute of the input that produced the value. The easiest way to guarantee it is to generate both from the same place. If the form markup comes from a component library, have the field component take the schema path as its only identifier and derive name, id and the error container’s id from it; if the server validates with a shared schema, its issue paths are then the same strings by construction. A cheap runtime check in development catches drift early: after rendering, walk the schema’s keys and warn about any path that form.elements.namedItem() cannot find.
Pairing the Mapper With an Error Summary
When a submission returns several errors, focusing the first field is necessary but not sufficient: the user also needs to know how many problems there are and where. Render an error summary at the top of the form listing every error as a link to its field, populated from the same errors object the mapper consumed. The summary uses the same messages as the inline errors, so users are not asked to reconcile two wordings. Focus handling then becomes a choice: move focus to the summary when there are several errors (so screen reader users hear the count first), or straight to the field when there is one. The pattern, including link behaviour and headings, is in building an accessible error summary.
function renderSummary(form: HTMLFormElement, errors: ServerErrors): void {
const box = form.querySelector<HTMLElement>("#error-summary")!;
const list = box.querySelector("ul")!;
list.replaceChildren(...Object.entries(errors).map(([name, msgs]) => {
const target = resolveTarget(form, name);
const li = document.createElement("li");
const a = Object.assign(document.createElement("a"), { textContent: msgs[0], href: target ? `#${target.id}` : "#" });
li.append(a);
return li;
}));
box.hidden = false;
box.querySelector<HTMLElement>("h2")!.textContent = `There ${Object.keys(errors).length === 1 ? "is 1 problem" : `are ${Object.keys(errors).length} problems`}`;
}
Frequently Asked Questions
How do I show server validation errors on the matching form fields?
Convert each error's path to the input's name, look it up with form.elements.namedItem(), call setCustomValidity() with the message, and finish with form.reportValidity() so focus moves to the first failing field in document order.
How do I apply a server error to a radio group?
namedItem returns a RadioNodeList for groups. Put the custom validity on the first enabled radio, and clear it when any member of the group changes.
What should happen to server errors that match no input?
Show them in a form-level summary or alert region. Never drop them; an invisible error leaves the user unable to submit and unable to see why.
Why does my server error stay after the user fixes the field?
Client-side rules do not know about server errors and will not clear them. Add a one-time input listener that clears the custom validity, and sweep away previous server errors before applying a new response.
Related Guides
- API Validation Error Contracts — the response format this mapper consumes.
- Handling 422 Unprocessable Content Responses — deciding when to call the mapper.
- Validating Dynamically Added Form Rows — stable row names that survive reordering.
- Managing Focus After Validation Failure — the focus rules applied after mapping.