Every API invents its own error envelope — { error: "…" }, { errors: [{ field, msg }] }, { message, details: { … } } — and every client grows a parser per endpoint, which is why server errors so often end up as a generic toast instead of on the field that caused them.
Problem Details, standardised in RFC 9457 (which obsoletes RFC 7807), gives HTTP APIs a common error format with type, title, status, detail and instance, plus extension members for anything specific. This page, part of server error reconciliation, defines a validation problem type with per-field errors as JSON Pointers, and a client that routes any problem response to the right place in the form.
Context and prerequisites
The standard members:
type— a URI identifying the problem type; the primary key for client logic. Defaults toabout:blank.title— a short, human-readable summary of the type (same for every occurrence).status— the HTTP status code, duplicated for convenience.detail— a human-readable explanation of this occurrence.instance— a URI identifying this occurrence (useful for support logs).
The media type is application/problem+json. RFC 9457 adds guidance for multiple problems of the same type, and describes an example validation problem that lists errors, each with a detail and a pointer — a JSON Pointer (RFC 6901) into the request body, such as #/address/postcode or /items/2/qty. That pointer is exactly what a form needs to place an error on a field.
The core pattern: a validation problem type and a routing client
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation",
"title": "Your request has invalid fields.",
"status": 422,
"detail": "2 fields need attention.",
"instance": "/requests/9f2c1e",
"errors": [
{ "pointer": "#/email", "detail": "That email is used by another account.", "code": "email_taken" },
{ "pointer": "#/items/2/qty", "detail": "Only 3 left in stock.", "code": "stock_limit" }
]
}
export interface Problem {
type?: string; title?: string; status?: number; detail?: string; instance?: string;
errors?: { pointer?: string; detail: string; code?: string }[];
[ext: string]: unknown;
}
export type Routed =
| { kind: "fields"; fields: { path: (string | number)[]; message: string; code?: string }[]; unplaced: string[] }
| { kind: "conflict"; message: string }
| { kind: "retryable"; message: string; retryAfterMs?: number; reference?: string }
| { kind: "form"; message: string; reference?: string };
const T = {
validation: "https://api.example.com/problems/validation",
conflict: "https://api.example.com/problems/edit-conflict",
rateLimited: "https://api.example.com/problems/rate-limited",
};
// "#/items/2/qty" or "/items/2/qty" → ["items", 2, "qty"] (RFC 6901 unescaping)
export function pointerToPath(pointer: string): (string | number)[] {
const p = pointer.startsWith("#") ? decodeURIComponent(pointer.slice(1)) : pointer;
return p.split("/").slice(1).map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~")).map((s) => (/^\d+$/.test(s) ? Number(s) : s));
}
export async function routeProblem(res: Response, knownPaths: Set<string>): Promise<Routed> {
const isProblem = (res.headers.get("Content-Type") ?? "").includes("application/problem+json");
const body: Problem = isProblem ? await res.json().catch(() => ({})) : {};
const reference = body.instance;
if (body.type === T.validation && body.errors) {
const fields: { path: (string | number)[]; message: string; code?: string }[] = [];
const unplaced: string[] = [];
for (const e of body.errors) {
const path = e.pointer ? pointerToPath(e.pointer) : [];
// Only place errors on fields the form actually renders; the rest go to the summary.
if (path.length && knownPaths.has(path.join("."))) fields.push({ path, message: e.detail, code: e.code });
else unplaced.push(e.detail);
}
return { kind: "fields", fields, unplaced };
}
if (body.type === T.conflict || res.status === 409) {
return { kind: "conflict", message: body.detail ?? "This record was changed by someone else." };
}
if (body.type === T.rateLimited || res.status === 429 || res.status >= 500) {
const ra = Number(res.headers.get("Retry-After"));
return { kind: "retryable", message: body.detail ?? "Something went wrong on our side. Please try again.",
retryAfterMs: Number.isFinite(ra) ? ra * 1000 : undefined, reference };
}
return { kind: "form", message: body.detail ?? body.title ?? "We couldn't save your changes.", reference };
}
Step-by-step walkthrough
- Define problem types as URIs you own.
…/problems/validation,…/problems/edit-conflict,…/problems/rate-limited. Ideally they resolve to documentation for developers. - Return validation failures with a stable
errorsextension. Each entry has a JSON Pointer to the offending input and a user-facingdetail, plus a machinecodefor translation. - Set
application/problem+json. Clients detect the format by media type, not by guessing at body shape. - Switch on
typein the client. Validation problems go to fields; conflicts go to reconciliation (see handling 409 conflicts on form submit); rate limits and 5xx become retryable banners; anything else is a form-level message. - Convert pointers to form paths and check they exist. Errors for fields the form does not render go to the summary instead of disappearing.
- Show
instanceas a reference on unexpected errors. “Reference 9f2c1e” lets support find the server log.
Why a standard format pays off in the form layer
The value of Problem Details is not the specific member names; it is that every endpoint returns errors the same way, so one client function handles them all. Without that, each form’s submit handler grows bespoke parsing for its endpoint’s quirks, and the handling of rarer cases — conflicts, rate limits, unexpected 500s — is inconsistent or missing. With a standard envelope and a small set of problem types, the routing logic lives in one tested module, and every form gets correct field placement, conflict handling and retry banners for free.
Failure modes and edge cases
1. Parsing HTML error pages as problems
Proxies and load balancers return HTML for 502 and 504. Check the Content-Type before parsing, and treat non-problem responses by status alone.
2. Pointers to renamed fields
If the API names a field postal_code and the form uses postcode, pointers will not match. Map API paths to form paths in one place — the normalisation described in normalizing nested field error paths — rather than in each form.
3. Indices after reorder
A pointer like #/items/2/qty refers to the order that was sent. Translate with a snapshot of that order, as in keeping array errors aligned after reorder and delete.
4. Leaking internals in detail
detail is shown to users; never put stack traces, SQL or internal ids in it. Log those server-side against the instance.
5. Localisation
title and detail are human-readable and should follow the request’s Accept-Language, or the client should translate from code. Mixed-language screens — English server messages in a German form — are a common symptom of skipping this.
Verification checklist
Frequently Asked Questions
Is 422 or 400 the right status for validation errors?
Both are used. 422 Unprocessable Content signals that the request was well-formed but semantically invalid, which fits form validation well; 400 is common for malformed bodies. Pick one convention for validation and keep 400 for requests the server could not parse at all.
Is the errors array part of the standard?
It is an extension member. RFC 9457 shows a validation example with an errors array of objects carrying detail and pointer, but extension names are up to the API. Document yours alongside the problem type URI.
Do framework error formats map onto this?
Many do or can: ASP.NET Core produces Problem Details natively (with a errors dictionary keyed by field name), Spring supports ProblemDetail, and other frameworks have middleware. Normalise their field keys to your pointer format in the client router.