Lesson 19 / 34
Validation Errors
Adding a field-level error list to problem details, the request-round difference between stopping at the first error and collecting all of them, and the body path rule that makes field names match the client's input boxes.
Contents
The previous lesson bound the error body to a single shape but left the validation type
in the problem catalog unused. The reason is that this type is structurally different from
the others. “No copy available” is a single fact; a sentence in the detail field
describes it completely. “Request body failed validation” is not a fact but a set of
facts: which fields of the body were rejected, by which rules?
The loan service’s lending request is no longer a single book; it carries a member, multiple items, and a return date. This lesson builds field-level error reporting and settles two decisions by measurement: how many errors get reported at once, and by what name the fields are referred to.
How Many Errors at Once
Validation can stop at the first field that violates a rule, or it can scan the whole body and collect the errors. The difference between them is how many times the user has to send a request to fix the form.
The validator below takes the schema as data. The schema keys are body paths and are
written in JSON Pointer form: /member, /items/1/isbn. The * marker expands over array
elements, so the /items/*/isbn pattern applies to every item in the body.
// validate.mjs — validates the body against a schema, produces a field-level error list // Field name is JSON Pointer (RFC 6901): "/member", "/items/1/isbn" export const RULES = { required: (d) => (d === undefined || d === null || d === "" ? { code: "required" } : null), pattern: (d, p) => (new RegExp(p).test(String(d)) ? null : { code: "format", param: p }), enum: (d, p) => (p.includes(d) ? null : { code: "not_in_enum", param: p }), array: (d, p) => !Array.isArray(d) ? { code: "not_an_array" } : d.length < p.min ? { code: "too_short", param: p } : d.length > p.max ? { code: "too_long", param: p } : null, date: (d) => (/^\d{4}-\d{2}-\d{2}$/.test(String(d)) ? null : { code: "not_a_date" }), maxDays: (d, p, today) => { const diff = Math.round((Date.parse(d) - Date.parse(today)) / 86400000); if (Number.isNaN(diff)) return null; // date rule already reported it if (diff < 0) return { code: "in_past" }; return diff > p ? { code: "too_far_ahead", param: p } : null; }, }; // The * inside a template is expanded to real array indices: /items/*/isbn -> /items/0/isbn const read = (body, path) => path.split("/").slice(1).reduce((d, p) => (d == null ? undefined : d[p]), body); function expandPaths(body, template) { const star = template.indexOf("/*"); if (star < 0) return [template]; const arr = read(body, template.slice(0, star)); if (!Array.isArray(arr)) return []; return arr.flatMap((_, i) => expandPaths(body, template.slice(0, star) + `/${i}` + template.slice(star + 2))); } // validate(schema, body, today) -> [{ path, code, param }] export function validate(schema, body, today) { const errors = []; for (const [template, rules] of Object.entries(schema)) { for (const path of expandPaths(body, template)) { const value = read(body, path); for (const [name, param] of rules) { if (name !== "required" && (value === undefined || value === null || value === "")) break; const result = RULES[name](value, param, today); if (result) { errors.push({ path, ...result }); break; } // one error per field } } } return errors; }
To compare the two collection styles, a body with four fields wrong at once is taken, and the user is assumed to fix only the errors reported to them.
// turns.mjs — compares stop-at-first-error validation with collect-all validation import { validate } from "./validate.mjs"; const TODAY = "2026-03-01"; const SCHEMA = { "/member": [["required"], ["pattern", "^U-\\d{4}$"]], "/items": [["required"], ["array", { min: 1, max: 3 }]], "/items/*/isbn": [["required"], ["pattern", "^97[89]-\\d{10}$"]], "/items/*/branch": [["required"], ["enum", ["central", "shore", "hill"]]], "/returnDate": [["required"], ["date"], ["maxDays", 30]], }; // The body the user first submits: four fields wrong at once. const FIRST = { member: "1001", items: [ { isbn: "978-0262033848", branch: "central" }, { isbn: "0262033848", branch: "sea" }, ], returnDate: "2026-06-01", }; // The user's fix order: whichever error is reported gets fixed. const FIXES = { "/member": (b) => (b.member = "U-1001"), "/items/1/isbn": (b) => (b.items[1].isbn = "978-0201896831"), "/items/1/branch": (b) => (b.items[1].branch = "shore"), "/returnDate": (b) => (b.returnDate = "2026-03-20"), }; function roundCount(collectAll) { const body = structuredClone(FIRST); let round = 0; while (round < 10) { round++; const errors = validate(SCHEMA, body, TODAY); if (errors.length === 0) return round; const reported = collectAll ? errors : [errors[0]]; console.log(` round ${round}: ${reported.map((h) => `${h.path}=${h.code}`).join(" ")}`); for (const h of reported) FIXES[h.path](body); } return round; } console.log("validation that stops at the first error:"); const a = roundCount(false); console.log(` request rounds until success: ${a}\n`); console.log("validation that collects all errors:"); const b = roundCount(true); console.log(` request rounds until success: ${b}`);
validation that stops at the first error: round 1: /member=format round 2: /items/1/isbn=format round 3: /items/1/branch=not_in_enum round 4: /returnDate=too_far_ahead request rounds until success: 5 validation that collects all errors: round 1: /member=format /items/1/isbn=format /items/1/branch=not_in_enum /returnDate=too_far_ahead request rounds until success: 2
Five rounds against two. The number itself depends on how many fields in the body are wrong — wrong fields mean rounds in stop-at-first-error validation — but the behavioral difference is independent of the count: stopping at the first error hides the existence of the next error from the user. The user thinks the job is done after every fix and gets rejected again.
One detail is preserved: the validator stops at the first error per field. The
/member field must be both required and pattern-matching; when it is left blank, there is
no benefit in reporting both the “required” and “format” errors, since the second is a
consequence of the first. The rule is: scanning continues across fields, but the first
error within a field is enough.
The Three Fields of an Error Record
Every error record carries three things. path is the field’s address in the body.
code is the error’s stable identifier — the same distinction as the validation
schemas built in the Application Architecture course in the Frontend Development
curriculum: the code is for branching, the message is for display. param is the
number the code requires: which pattern, which options, how many days.
The code standing in for the message makes it possible to produce the text shown to the
user on the client side. The server sends too_far_ahead and 30; the client turns
this into a sentence in its own language and its own tone. If the server sent text,
translation, pluralization rules, and tone decisions would move onto the server; none of
that is the server’s job.
This triple is added to problem details as an extension member. The five core fields
stay in place; the errors array joins them.
// server.mjs — service that validates a loan request and returns 422 problem details import { createServer } from "node:http"; import { validate } from "./validate.mjs"; const TODAY = "2026-03-01"; // fixed so the example is reproducible const SCHEMA = { "/member": [["required"], ["pattern", "^U-\\d{4}$"]], "/items": [["required"], ["array", { min: 1, max: 3 }]], "/items/*/isbn": [["required"], ["pattern", "^97[89]-\\d{10}$"]], "/items/*/branch": [["required"], ["enum", ["central", "shore", "hill"]]], "/returnDate": [["required"], ["date"], ["maxDays", 30]], }; // The one entry from the previous lesson's problem catalog that this lesson needs. let counter = 0; const validationProblem = (errors) => ({ type: "https://example.library/problems/validation", title: "Request body failed validation", status: 422, detail: `${errors.length} fields were not accepted.`, instance: `oc-${String(++counter).padStart(4, "0")}`, errors, }); const readBody = (req) => new Promise((resolve) => { let v = ""; req.on("data", (p) => (v += p)); req.on("end", () => resolve(v)); }); // Core fields stacked, each error on its own line: keep the body readable. const format = (s) => [ "{", ...["type", "title", "status", "detail", "instance"].map((a) => ` "${a}": ${JSON.stringify(s[a])},`), ' "errors": [', ...s.errors.map((h, i) => ` ${JSON.stringify(h)}${i < s.errors.length - 1 ? "," : ""}`), " ]", "}", ].join("\n"); createServer(async (req, res) => { res.sendDate = false; const body = JSON.parse((await readBody(req)) || "{}"); const errors = validate(SCHEMA, body, TODAY); if (errors.length) { res.writeHead(422, { "content-type": "application/problem+json; charset=utf-8" }); return res.end(format(validationProblem(errors))); } res.writeHead(201, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ id: "O-2", member: body.member, items: body.items.length })); }).listen(8433, "127.0.0.1", () => console.log("validation server 127.0.0.1:8433"));
#!/usr/bin/env bash # Sends a loan request with four wrong fields; shows the 422 body. node server.mjs & server=$! sleep 0.5 curl -sS -w '\n[%{http_code}] %{content_type}\n' -X POST -H 'content-type: application/json' \ -d '{"member":"1001","items":[{"isbn":"978-0262033848","branch":"central"},{"isbn":"0262033848","branch":"sea"}],"returnDate":"2026-06-01"}' \ http://127.0.0.1:8433/loans echo "--- fixed body ---" curl -sS -w '\n[%{http_code}] %{content_type}\n' -X POST -H 'content-type: application/json' \ -d '{"member":"U-1001","items":[{"isbn":"978-0262033848","branch":"central"},{"isbn":"978-0201896831","branch":"shore"}],"returnDate":"2026-03-20"}' \ http://127.0.0.1:8433/loans kill "$server"; wait "$server" 2>/dev/null
validation server 127.0.0.1:8433
{
"type": "https://example.library/problems/validation",
"title": "Request body failed validation",
"status": 422,
"detail": "4 fields were not accepted.",
"instance": "oc-0001",
"errors": [
{"path":"/member","code":"format","param":"^U-\\d{4}$"},
{"path":"/items/1/isbn","code":"format","param":"^97[89]-\\d{10}$"},
{"path":"/items/1/branch","code":"not_in_enum","param":["central","shore","hill"]},
{"path":"/returnDate","code":"too_far_ahead","param":30}
]
}
[422] application/problem+json; charset=utf-8
--- fixed body ---
{"id":"O-2","member":"U-1001","items":2}
[201] application/json; charset=utf-8
The status code being 422, not 400, is a deliberate choice. 400 reports that the request could not be understood by the server: malformed JSON, a missing header, an unrecognized content type. The body here has been understood completely — parsed, its fields read, run through the rules — and its meaning was rejected. The distinction is useful in the log too: a rise in the 400 rate shows that the client built the request wrong, a rise in the 422 rate shows that the user entered wrong data; the two have different causes and different fixes.
The Field Name’s Counterpart on the Client
For the error list to be useful, the client has to know next to which input box to show each error. The error presentation in the Accessible Component Patterns course assumes this match: each error is bound to its own field’s accessible description, and the error summary moves focus to that field. If the match cannot be established, all that is left is a single generic message.
The measurement below reports the same four errors under three separate server contracts and counts how many of them the client can match to their box.
// match.mjs — measures the client-side match rate of three field naming conventions // The client's input boxes are named by the contract's rule: // name = JSON Pointer with the leading slash dropped and "/" replaced by "." const BOXES = ["member", "items.0.isbn", "items.0.branch", "items.1.isbn", "items.1.branch", "returnDate"]; const toBoxName = (path) => path.replace(/^\//, "").replaceAll("/", "."); // The same four errors, reported under three separate server contracts. const CONTRACTS = { "no field name": { errors: [{ message: "The information you submitted has four errors." }], toBox: () => null, }, "server's internal names": { errors: [ { field: "member_id", code: "format" }, { field: "items[1].isbn", code: "format" }, { field: "items[1].branch", code: "not_in_enum" }, { field: "due_date", code: "too_far_ahead" }, ], toBox: (h) => h.field, }, "body path (JSON Pointer)": { errors: [ { path: "/member", code: "format" }, { path: "/items/1/isbn", code: "format" }, { path: "/items/1/branch", code: "not_in_enum" }, { path: "/returnDate", code: "too_far_ahead" }, ], toBox: (h) => toBoxName(h.path), }, }; console.log("contract reported matched can be shown next to its box"); for (const [name, { errors, toBox }] of Object.entries(CONTRACTS)) { const matched = errors.filter((h) => BOXES.includes(toBox(h))); console.log( `${name.padEnd(27)} ${String(errors.length).padStart(9)} ${String(matched.length).padStart(7)} ` + `${matched.length ? matched.map((h) => toBox(h)).join(", ") : "none"}` ); }
contract reported matched can be shown next to its box no field name 1 0 none server's internal names 4 0 none body path (JSON Pointer) 4 4 member, items.1.isbn, items.1.branch, returnDate
The second row is worse than the first, because it is deceptive: the server appears to
report four separate errors, and there are field names too, but those names come from the
server’s own internal data model. member_id, items[1].branch — there is no such field
in the client’s body. The client either keeps a hand-written translation table between the
server’s internal names and its own field names, or shows the errors unmatched. If a
translation table is kept, every internal rename on the server breaks the client; in other
words, something outside the contract starts behaving like part of the contract.
The rule is: the error path is the address of the field in the body the client sent. Not the server’s internal model names, but the structure of the request body. This rule guarantees two properties. It is total — every field in the body has a path, array elements included. And it is derivable in one direction: because the client builds the body itself, it can write the rule that converts a path into a box name, without needing to keep a table in the reverse direction.
The only remaining decision is which format the body path is written in. JSON Pointer is a defined standard, expresses array indices naturally, and has well-defined escaping rules. A dot separator could be chosen instead; what matters is not the format itself but that it is documented and defined for every field.
Summary
- When validation stops at the first error, wrong fields require request rounds; when errors are reported in bulk, two rounds are enough and the existence of the next error is not hidden from the user.
- Scanning continues across fields, but the first error within a field is enough: successive rule violations on the same field are consequences of each other.
- An error record carries three fields — path, code, parameter; the text shown to the user is produced on the client from the code and the parameter, not from the server.
- A request that could not be understood is reported with 400, a request that was understood but whose meaning was rejected is reported with 422; the distinction also separates the cause behind a rise in the log.
- The error path is the address in the body the client sent, not the server’s internal model name; when internal names are reported, the match rate drops to zero out of four.
- The field naming convention must be total and derivable in one direction; what matters is not which format is chosen but that it is documented and defined for every field.
Next Step
The validation schema was taken as fixed in this lesson: /items accepts at most three
elements, the return date can be at most thirty days ahead. What happens when the library
decides to change these limits? Raising the item limit to five breaks no client; lowering
it to three breaks every client that sends a request with four items. Renaming the same
body field, however, breaks every client at once. Change to the contract is inevitable; the
next lesson takes up the mechanism that makes this change manageable, and by publishing two
versions of the same resource on a single server, compares the cost to client code of
path-based, header-based, and content-based versioning.
To keep your progress and take notes, Log in
My notes
Log in to take notes.