Skip to content
academia.sh

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 — nn wrong fields mean n+1n+1 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, nn wrong fields require n+1n+1 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.

Start typing to search.

↑↓ Esc navigate · open · close