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.

Anatomy of a validation problem document Cards describing each member of an RFC 9457 validation problem document, from the standard type, title, status, detail and instance members to the errors extension. type URI naming the problem kind: /problems/validation title short summary, same for every occurrence status the HTTP status, repeated for logs detail this occurrence: "2 fields need attention" instance URI of this occurrence, for support errors (extension) pointer + code + detail per field
Five standard members identify and summarise the problem; the errors extension carries the per-field detail forms need.

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.

Content negotiation for problem details A client sends a request accepting JSON and problem JSON; the server validates, fails, and responds with a 422 problem document whose content type tells the client to parse it as validation errors. Client API POST /orders, Accept: application/json, application/problem+json schema fails: /items/1/qty below minimum 422, Content-Type: application/problem+json type = …/problems/validation → applyProblem()
The media type is the switch: application/problem+json tells any client, even one that has never seen your API, that the body describes an error.

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.

Client dispatch on a problem document A decision tree the client follows on receiving an error response: check the media type, then the problem type, and route to field errors, a conflict flow or a generic message. Content-Type is application/ problem+json? no Generic failure message yes type is validation? yes applyProblem → fields + focus no Known conflict type? yes Run its recovery flow no Show title as form-level alert
Branching on the problem type rather than the status code lets two different 409s get two different, correct experiences.

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.

← Back to API Validation Error Contracts