API Validation Error Contracts

A form is only as good as the errors its API returns. The browser can validate formats and lengths, but uniqueness, permissions, stock levels, fraud checks and business rules only exist on the server — and when those fail, the server’s response is the entire user experience. Too many APIs answer with 400 Bad Request and {"error": "Validation failed"}, or a stack trace, or a list of messages with no indication of which field they belong to. The front-end then shows a generic banner, the user cannot tell what to fix, and support tickets follow. This topic defines a validation error contract: the status codes, the response shape, the way each error points at a specific input, and the client code that turns the response back into field-level errors through setCustomValidity() and the Constraint Validation API, exactly as if the browser had found them itself.

The failure mode this topic eliminates is the orphaned server error: a rejection the user can see but cannot act on, because it is not attached to anything they can change.

The validation error contract end to end A request fails server rules, the server responds with 422 and a problem-details body listing errors with pointers, the client resolves each pointer to an input, and the errors are reported through the native validation API. Server rule fails uniqueness, stock, policy 422 response application/ problem+json errors[] pointer + key + message Client resolver pointer → input name Native report setCustomValidity + reportValidity
The contract's job is to carry "which input, what message" across the network intact.

Prerequisites for an Error Contract

Requirement Minimum version Why it is needed
TypeScript 5.0+ Shared types for the error body on client and server
A server framework with typed responses any One helper builds every validation response
JSON Pointer (RFC 6901) knowledge /address/city style references to fields
fetch + Response.json() All browsers Reading structured error bodies
Shared message catalogue (optional) Localised rendering from message keys
API documentation (OpenAPI 3.1) Publishing the error schema for other clients

Error Contract Reference

Element Type Required Purpose
HTTP status 422 yes “Syntactically fine, semantically invalid” — the validation status
Content-Type application/problem+json yes Standard error media type (RFC 9457)
type URI string yes Identifies the problem kind, e.g. /problems/validation
title string yes Short, human summary: “Your request has invalid fields”
status number recommended Repeats the HTTP status for logs and proxies
errors[] array yes (extension) One entry per failed rule
errors[].pointer JSON Pointer yes Location in the submitted body: /email, /items/2/qty
errors[].code string yes Stable machine key: email.taken
errors[].detail string yes Human message in the request’s language
errors[].params object optional Values for client-side rendering: { "min": 12 }

Step-by-Step Implementation

1. Pick the status codes and stick to them

Situation Status Body
Field values violate rules 422 Unprocessable Content Problem details with errors[]
Body is not parseable JSON / form data 400 Bad Request Problem details, no field errors
Not signed in / not allowed 401 / 403 Problem details; never field errors
Resource changed since the form loaded 409 Conflict or 412 Problem details; form-level message
Too many attempts 429 Too Many Requests Problem details + Retry-After
Upload too large 413 Content Too Large Problem details pointing at the file field

Keeping validation on 422 lets clients handle it generically: any 422 is “show these errors on these fields”, anything else is a different flow. The status-code semantics and client handling are detailed in handling 422 Unprocessable Content responses.

2. Define the body type once and share it

// packages/validation/src/problem.ts — shared by server and client
export interface FieldProblem {
  pointer: string;              // RFC 6901 JSON Pointer into the request body
  code: string;                 // stable key, e.g. "email.taken"
  detail: string;               // rendered message (server locale)
  params?: Record<string, string | number>;
}

export interface ValidationProblem {
  type: "/problems/validation";
  title: string;
  status: 422;
  detail?: string;              // form-level summary or form-level error
  errors: FieldProblem[];
}

export const isValidationProblem = (b: unknown): b is ValidationProblem =>
  typeof b === "object" && b !== null && (b as any).type === "/problems/validation" && Array.isArray((b as any).errors);

3. Build every validation response through one helper

// server
import type { ZodError } from "zod";
import type { FieldProblem, ValidationProblem } from "@acme/validation/problem";

const toPointer = (path: (string | number)[]) =>
  "/" + path.map((p) => String(p).replace(/~/g, "~0").replace(/\//g, "~1")).join("/");

export function validationProblem(errors: FieldProblem[], detail?: string): Response {
  const body: ValidationProblem = {
    type: "/problems/validation",
    title: "Your request has invalid fields",
    status: 422,
    detail,
    errors,
  };
  return new Response(JSON.stringify(body), { status: 422, headers: { "content-type": "application/problem+json" } });
}

export function fromZod(error: ZodError): FieldProblem[] {
  return error.issues.map((i) => ({ pointer: toPointer(i.path), code: `schema.${i.code}`, detail: i.message }));
}

// in a route
const parsed = schema.safeParse(body);
if (!parsed.success) return validationProblem(fromZod(parsed.error));
if (await users.exists(parsed.data.email)) {
  return validationProblem([{ pointer: "/email", code: "email.taken", detail: "An account with this email already exists." }]);
}

The full format, including extension members and how it maps to OpenAPI, is covered in problem details (RFC 9457) for field errors.

4. Map pointers back to inputs on the client

import { isValidationProblem } from "@acme/validation/problem";

/** "/address/city" → "address.city"; "/items/2/qty" → "items.2.qty" (the input's name). */
const pointerToName = (p: string) =>
  p.slice(1).split("/").map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~")).join(".");

export async function submitWithContract(form: HTMLFormElement): Promise<boolean> {
  const res = await fetch(form.action, { method: "POST", body: new FormData(form), headers: { accept: "application/json" } });
  if (res.ok) return true;
  const body = await res.json().catch(() => null);
  if (res.status !== 422 || !isValidationProblem(body)) {
    showFormError(form, "Something went wrong. Please try again.");
    return false;
  }
  const unmatched: string[] = [];
  for (const e of body.errors) {
    const el = form.elements.namedItem(pointerToName(e.pointer));
    if (el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement) {
      el.setCustomValidity(e.detail);
      el.addEventListener("input", () => el.setCustomValidity(""), { once: true });
    } else {
      unmatched.push(e.detail);     // pointer to a field the form doesn't render
    }
  }
  if (unmatched.length || body.detail) showFormError(form, [body.detail, ...unmatched].filter(Boolean).join(" "));
  form.reportValidity();
  return false;
}

Two defensive details matter. The input listener with once: true clears a server-originated error as soon as the user edits the field — client rules do not know about server errors and would never clear them otherwise. And errors whose pointer matches no input are never dropped: they become a form-level message, because an error the user never sees is worse than one shown in the wrong place. The detailed mapping, including arrays and radio groups, is mapping server field errors to form inputs.

Server-side uniqueness failure mapped to a field The client submits a form, the server finds the email already registered and replies with a 422 problem details body pointing at /email, and the client applies the message to the email input and reports it. Browser API Database POST /signup (FormData) users.exists("ada@example.com") true 422 problem+json, pointer "/email", code "email.taken" email.setCustomValidity(detail); form.reportValidity()
The pointer "/email" is all the client needs to put the server's message on the right input and move focus there.

State Management and Edge Cases

Server errors live in a different state machine from client errors. Client rules re-run on every input and can clear themselves; server errors are a snapshot of one request and go stale the moment the user edits the field or the underlying data changes.

Lifecycle of a server-originated field error A field error from the server is applied after a 422, cleared when the user edits that field, and replaced or cleared again on the next submission. no server error submitting server error shown edited, pending resubmit submit 422 with pointer 2xx user edits field submit
Server errors are snapshots: they clear on edit and are recomputed only by the next submission.
  • Do not re-apply stale errors. Keep server errors out of any client-side error store that re-renders on every change, or an edited field keeps showing “already taken” for a value it no longer has.
  • Concurrent submissions. Disable the submit button or ignore responses from superseded submissions; a slow 422 for an old attempt must not overwrite the result of a newer one. The pattern is in preventing double form submission.
  • Multiple errors on one field. Show the first; the rest usually resolve with it. If they are independent, join them into one message rather than stacking several setCustomValidity calls, which simply overwrite each other.

Accessibility Compliance for Server Errors

WCAG makes no distinction between client and server errors: 3.3.1 Error Identification requires the item in error to be identified and described in text, and 3.3.3 Error Suggestion requires a suggestion when one is known. A contract that points at fields is what makes those criteria achievable for server errors at all; a generic “Validation failed” banner fails both. After applying errors, call reportValidity() so focus moves to the first failing field and its message is announced, and render a summary at the top of the form when there are several errors, with links to each field. For form-level errors (a 409 conflict, a 429 rate limit), use a single role="alert" region so the message is announced once, and keep focus where it is unless the user must act in a different place.

The server must never put sensitive information in detail just because the client will display it. “Card declined: suspected fraud rule 7” helps attackers; “Your card was declined. Try a different card or contact your bank.” helps users.

Common Gotchas and Debugging

400 for everything. Clients cannot tell a malformed request from a rule failure, so they cannot decide whether to show field errors.

Messages without locations. {"errors": ["Email already exists"]} forces the client to guess the field from the text.

// Before
{ "error": "Validation failed", "errors": ["Email already exists"] }
// After
{ "type": "/problems/validation", "title": "Your request has invalid fields", "status": 422,
  "errors": [{ "pointer": "/email", "code": "email.taken", "detail": "An account with this email already exists." }] }

Pointers that do not match input names. The server validates emailAddress while the form’s input is name="email". Keep request field names equal to input names, or maintain one explicit mapping table shared by both sides.

Server errors that never clear. Without the once: true input listener, the custom validity set from the server persists after the user fixes the value, and the form can never be resubmitted.

Leaking internals. Stack traces, SQL fragments and library messages in detail are both a security issue and useless to users. Log them; return the human message.

Field-Level Versus Form-Level Errors

Not every server failure belongs to a field, and forcing one onto a field is as confusing as leaving a field error unattached. The contract distinguishes them structurally: entries in errors[] always carry a pointer and belong to an input; the top-level detail carries anything that applies to the submission as a whole. A useful test is to ask what the user must change to succeed. If the answer is “this value”, it is a field error. If the answer is “nothing on this form — wait, sign in again, use another card, contact support”, it is a form-level error, and it usually has a non-422 status as well.

Field-level and form-level server errors Two columns contrasting errors that belong to a specific input with errors that apply to the whole submission, with examples and how each is shown. Field-level (errors[]) • email already registered • quantity exceeds stock for line 2 • coupon code expired ✓ setCustomValidity on the input ✓ focus moves to the first one Form-level (detail) • session expired, sign in again • payment provider unavailable • record changed by someone else ✓ one role="alert" region ✓ focus stays unless action elsewhere
Field errors carry a pointer and move focus to an input; form-level errors carry no pointer and are announced once in an alert region.

Some failures are genuinely both. A “stock changed” conflict on checkout affects specific line items and the whole order. Return field errors for the affected lines and a form-level detail that summarises (“Some items in your basket have changed”), so the user gets both the overview and the precise locations.

Security-Sensitive Validation Responses

The contract must not become an oracle for attackers. Three categories need care. Account enumeration: “An account with this email already exists” on a sign-up form confirms that the email is registered. For most consumer products that trade-off is accepted because the alternative confuses real users; for sensitive services, respond identically for existing and new addresses and send an email instead. Credential checks: sign-in failures are always form-level and deliberately vague (“Email or password is incorrect”), never pointed at one field. Rate limits and fraud rules: a 429 with Retry-After is fine, but never explain which fraud rule fired. Keep a list of codes that are safe to expose in the API documentation, and have the error helper refuse to emit any code not on it, so an internal code cannot leak by accident — the principles are set out in why client-side validation is not security.

Designing the Contract for Non-Form Clients Too

An API rarely has one client. The same endpoint may serve the web form, a mobile app and a partner integration, and the contract should serve all three without special cases. Stable code values are what make that possible: the web client renders detail or a localised catalogue message, the mobile app maps code to its own strings, and the partner’s integration logs code and pointer for debugging. Document the codes an endpoint can return in its OpenAPI description — an enum of codes per endpoint is ideal — and treat removing or renaming a code as a breaking change. Adding a new code is safe as long as clients have a fallback for unknown codes, which the client above does by displaying detail.

# openapi.yaml (excerpt)
components:
  schemas:
    ValidationProblem:
      type: object
      required: [type, title, status, errors]
      properties:
        type: { const: /problems/validation }
        title: { type: string }
        status: { const: 422 }
        errors:
          type: array
          items:
            type: object
            required: [pointer, code, detail]
            properties:
              pointer: { type: string, example: /email }
              code: { type: string, enum: [email.taken, email.invalid, password.breached, schema.too_small] }
              detail: { type: string }

Localising Contract Messages

The detail string in each error is rendered in one language, but the user may read another. Two approaches work. The server can honour the request’s Accept-Language header and render detail in that language from the same catalogue the client uses; this keeps clients simple and suits server-rendered forms. Or the server can treat detail as a developer-facing fallback and let clients render code plus params in the user’s locale, which suits single-page apps that already hold a message catalogue. In both cases, the stable code is what makes localisation possible at all — without it, a client can only display whatever language the server happened to choose. The catalogue mechanics are covered in keeping client and server error messages in sync.

Testing the Contract

Contract tests catch the drift that unit tests miss: a route that returns 400 instead of 422 after a refactor, a pointer renamed on the server but not in the form, a new rule that forgot its code. Write one test per route that submits a known-invalid body and asserts the status, the media type, and the exact list of {pointer, code} pairs. Then add a browser test that mocks that response and asserts the message lands on the right input with aria-invalid and focus — the technique shown in testing form error messages with Playwright.

import { test, expect } from "@playwright/test";

test("422 problem details land on the email field", async ({ page }) => {
  await page.route("**/api/signup", (route) => route.fulfill({
    status: 422,
    contentType: "application/problem+json",
    body: JSON.stringify({ type: "/problems/validation", title: "Invalid", status: 422,
      errors: [{ pointer: "/email", code: "email.taken", detail: "An account with this email already exists." }] }),
  }));
  await page.goto("/signup");
  await page.getByLabel("Email address").fill("ada@example.com");
  await page.getByLabel("Password").fill("correct horse battery staple");
  await page.getByRole("button", { name: "Create account" }).click();
  await expect(page.getByLabel("Email address")).toBeFocused();
  expect(await page.getByLabel("Email address").evaluate((el: HTMLInputElement) => el.validationMessage))
    .toBe("An account with this email already exists.");
});

Browser Compatibility Matrix

Feature Chromium Firefox Safari Notes
form.elements.namedItem(name) Yes Yes Yes Returns a RadioNodeList for radio groups
setCustomValidity on select / textarea Yes Yes Yes Same API as inputs
Response.json() on error statuses Yes Yes Yes fetch does not throw on 4xx
AbortSignal.timeout 103+ 100+ 16+ For bounding submission requests

Frequently Asked Questions

What HTTP status should validation errors use?

422 Unprocessable Content for requests that are well-formed but break rules. Keep 400 for unparseable requests, 401 and 403 for authentication and permissions, 409 for conflicts and 429 for rate limits.

How should an API tell the client which field an error belongs to?

Include a JSON Pointer to the field in the request body, such as /email or /items/2/qty, along with a stable error code and a human message. Keep request field names equal to input names so the pointer maps directly.

What format should validation error responses use?

RFC 9457 problem details with the application/problem+json media type, extended with an errors array of pointer, code and detail entries. It is standard, documented and easy for any client to consume.

How do I clear a server error once the user fixes the field?

When applying a server error with setCustomValidity(), add a one-time input listener that clears it. Client-side rules do not know about server errors and will not clear them on their own.

← Back to Server and Full-Stack Validation

Explore This Section