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.

Resolving a server error to a control Each server error path is converted to an input name, looked up with form.elements.namedItem, normalised for radio groups and hidden fields, and either given a custom validity or collected as a form-level message. Error path items.2.qty namedItem( name) element or RadioNodeList Normalise target first radio, visible proxy setCustomValidi ty clear once on input Unmatched form-level summary
Nothing is dropped: every error lands on a visible control or in the form-level summary.

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.

Server errors applied to a checkout form A checkout form after a 422 response, with a stock error on the third line item, an error on the delivery option radio group and a form-level summary for an error with no matching input. Review your order 1 problem couldn't be matched to a field: Your saved card has expired. 1 Line 3 quantity (items.2.qty) 5 2 ✗ Only 3 left in stock. ✗ Standard delivery (radio, first in group) ○ Express delivery Town or city (address.city) 3 ✗ Enter a town or city. Place order 1 Errors with no matching input go to the form-level summary, never dropped 2 Dotted and indexed names resolve with form.elements.namedItem 3 reportValidity focuses the first failing control in document order
Nested and indexed errors land on their inputs, the radio group's first option carries the group error, and the unmatched error appears in the summary.

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.

Development-time check for unmatched paths In development, after the form renders, a checker walks every path the schema can report and warns when no input carries that name. Form render Path checker Schema form mounted list reportable paths email, address.city, items.*.qty namedItem() for each; warn on misses
Catching a renamed input during development is far cheaper than discovering an unmatched error in production.

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.

← Back to API Validation Error Contracts