Problem Details (RFC 9457) for Field Errors
How do you return validation failures in a format that any client — your web form, a mobile app, a partner’s integration, an API gateway — can understand without reading your documentation first? RFC 9457, “Problem Details for HTTP APIs” (the successor to RFC 7807), defines exactly that: an application/problem+json body with a small set of standard members. It deliberately leaves field-level errors to extensions, so this recipe adds a well-defined errors extension with JSON Pointers, stable codes and human messages, builds it on the server from schema failures, and parses it on the client into setCustomValidity() calls that feed the Constraint Validation API.
When to Use Problem Details for Validation Errors
Use problem details for every error response an API returns — validation and otherwise — once more than one client consumes it or once you want tooling (API gateways, observability, generated SDKs) to understand errors. It is the right choice when:
- You are designing a new API and have no legacy error format to preserve.
- Several clients — web, mobile, third parties — need consistent error handling.
- You document the API with OpenAPI, where a shared problem schema can be referenced by every operation.
If you already have a stable, well-documented error format, keep it and add pointers and codes to it rather than migrating for its own sake. The overall contract — status codes, field versus form errors — is set out in the API validation error contracts topic.
Minimal Working Problem Details Implementation
// shared/problem.ts
export const VALIDATION_TYPE = "https://www.example.com/problems/validation" as const;
export interface FieldError {
pointer: `/${string}`; // RFC 6901 JSON Pointer into the request body
code: string; // stable machine-readable key
detail: string; // human-readable message
params?: Record<string, string | number>;
}
export interface ValidationProblemDocument {
type: typeof VALIDATION_TYPE;
title: string;
status: 422;
detail?: string;
instance?: string;
errors: FieldError[];
}
// server/problem-response.ts
import { randomUUID } from "node:crypto";
import type { ZodIssue } from "zod";
const escapeToken = (t: string | number) => String(t).replace(/~/g, "~0").replace(/\//g, "~1");
export const pointerFrom = (path: (string | number)[]) => `/${path.map(escapeToken).join("/")}` as const;
export function problemFromIssues(issues: ZodIssue[], extra: FieldError[] = []): Response {
const errors: FieldError[] = [
...issues.map((i) => ({ pointer: pointerFrom(i.path) as FieldError["pointer"], code: `schema.${i.code}`, detail: i.message })),
...extra,
];
const body: ValidationProblemDocument = {
type: VALIDATION_TYPE,
title: "Your request contains invalid fields.",
status: 422,
detail: `${errors.length} field${errors.length === 1 ? "" : "s"} need${errors.length === 1 ? "s" : ""} attention.`,
instance: `/errors/${randomUUID()}`, // log with the same id for support lookups
errors,
};
return new Response(JSON.stringify(body), {
status: 422,
headers: { "content-type": "application/problem+json", "content-language": "en" },
});
}
// client/problem.ts
export async function readValidationProblem(res: Response): Promise<ValidationProblemDocument | null> {
const type = res.headers.get("content-type") ?? "";
if (res.status !== 422 || !type.startsWith("application/problem+json")) return null;
const body = await res.json().catch(() => null);
return body?.type === VALIDATION_TYPE && Array.isArray(body.errors) ? (body as ValidationProblemDocument) : null;
}
export function applyProblem(form: HTMLFormElement, problem: ValidationProblemDocument): void {
for (const err of problem.errors) {
const name = err.pointer.slice(1).split("/").map((t) => t.replace(/~1/g, "/").replace(/~0/g, "~")).join(".");
const field = form.elements.namedItem(name);
if (field && "setCustomValidity" in field) {
(field as HTMLInputElement).setCustomValidity(err.detail);
field.addEventListener("input", () => (field as HTMLInputElement).setCustomValidity(""), { once: true });
}
}
form.reportValidity();
}
The type is an absolute URI you control. It does not have to resolve, but making it resolve to a documentation page (“what this problem means and how to fix it”) is a small kindness to developers integrating with your API. The instance URI, logged server-side with the same identifier, lets support staff find the exact failing request from a screenshot.
Problem Document Member Reference
| Member | Type | Required | Guidance |
|---|---|---|---|
type |
URI string | yes (defaults to about:blank) |
One URI per problem kind; validation uses one fixed URI |
title |
string | recommended | Same text every time for this type; do not interpolate values |
status |
integer | recommended | Must match the HTTP status |
detail |
string | optional | This occurrence, for humans; not for parsing |
instance |
URI string | optional | Identifies this occurrence for support and logs |
errors |
array | extension | Field-level failures; name documented in your API |
errors[].pointer |
JSON Pointer | extension | Location in the request body; "" means the whole body |
errors[].code |
string | extension | Stable, documented machine key |
errors[].detail |
string | extension | Human message for this field |
RFC 9457 is explicit that clients must not parse detail for information; anything a program needs belongs in an extension member. That is why code and pointer exist alongside detail in each error: humans read detail, programs read the other two.
Verification Steps
import { describe, it, expect } from "vitest";
import { z } from "zod";
import { problemFromIssues, pointerFrom } from "./problem-response";
describe("problem details", () => {
it("builds a spec-conformant validation problem", async () => {
const schema = z.object({ items: z.array(z.object({ qty: z.number().min(1, "Quantity must be at least 1.") })) });
const parsed = schema.safeParse({ items: [{ qty: 2 }, { qty: 0 }] });
const res = problemFromIssues(!parsed.success ? parsed.error.issues : []);
expect(res.status).toBe(422);
expect(res.headers.get("content-type")).toBe("application/problem+json");
const body = await res.json();
expect(body).toMatchObject({ status: 422, errors: [{ pointer: "/items/1/qty", code: "schema.too_small" }] });
});
it("escapes pointer tokens", () => expect(pointerFrom(["a/b", "c~d"])).toBe("/a~1b/c~0d"));
});
Edge Cases and Failure Modes
Proxies that replace error bodies. Some API gateways and CDNs substitute their own HTML error pages for 4xx responses. Configure them to pass through application/problem+json bodies, and have the client treat a 422 without that media type as a generic failure rather than crashing on JSON.parse.
Pointers into query parameters or headers. JSON Pointer addresses the request body. For errors in query strings or headers, use a different extension member (parameter, header) rather than inventing pointer syntax; forms rarely need these, but APIs shared with forms often do.
Root-level errors. A pointer of "" refers to the whole document — for example, “at least one contact method is required”. Treat it as form-level on the client, since no single input owns it.
Localisation. title and detail are rendered in one language; set Content-Language accordingly and honour Accept-Language when you can. Clients that localise themselves should use code, as described in keeping client and server error messages in sync.
Logging and Support With the instance Member
The instance member is the most overlooked part of the specification and the most useful in production. Generate a unique identifier per error response, include it in the problem document, and write it to your server logs together with the request’s route, the error codes and a timestamp — but not the submitted values, which may be personal data. When a user contacts support with “the form keeps rejecting my address”, the identifier shown in small print under the error summary (“Reference: 7f3c…”) lets support find the exact request, see which rule fired and whether it was a genuine mistake or an over-strict rule. Over time those lookups are the best source of the real-world fixtures that keep validation fair, the practice recommended throughout the validating common input types section.
Using Problem Details for Every Error, Not Only Validation
Once clients can read problem documents, use them everywhere: authentication failures, permission errors, conflicts, rate limits and server faults. Each gets its own type URI and title, and clients branch on type rather than on status alone, which is far more precise — a 409 might mean “edited by someone else” or “slot already booked”, and those need different UI. A small registry of problem types in the shared package gives both sides the list.
export const PROBLEMS = {
validation: { type: "https://www.example.com/problems/validation", title: "Your request contains invalid fields.", status: 422 },
staleEdit: { type: "https://www.example.com/problems/stale-edit", title: "This record changed while you were editing it.", status: 409 },
slotTaken: { type: "https://www.example.com/problems/slot-taken", title: "That time is no longer available.", status: 409 },
rateLimited: { type: "https://www.example.com/problems/rate-limited", title: "Too many attempts. Please wait and try again.", status: 429 },
} as const;
The client then dispatches on type: validation problems go through applyProblem, a stale edit reloads the latest values and explains, a taken slot refreshes the slot list and focuses it. This mirrors the field-level versus form-level split in the contract topic, expressed as data instead of scattered if statements.
Frequently Asked Questions
What is RFC 9457?
It is the IETF standard for problem details in HTTP APIs, published in 2023 and replacing RFC 7807. It defines an application/problem+json body with type, title, status, detail and instance members, and allows extension members.
How do I include field-level errors in problem details?
Add an extension member, such as an errors array, where each entry has a JSON Pointer to the field, a stable code and a human message. Document the extension alongside your problem types.
Should clients parse the detail member?
No. The specification says detail is for humans. Put anything a program needs — field locations, error codes, parameters — in extension members.
Does the type URI need to resolve?
It does not have to, but pointing it at a documentation page that explains the problem and how to fix it helps developers integrating with your API.
Related Guides
- API Validation Error Contracts — status codes and the overall contract.
- Mapping Server Field Errors to Form Inputs — resolving pointers to inputs, including arrays.
- Handling 422 Unprocessable Content Responses — client behaviour for each status.
- Validating FormData on the Server with Zod — where the issues in these documents come from.