---
title: 'Validation Errors'
source: 'https://academia.sh/en/courses/api-design/validation-errors'
course: 'Web API Design'
language: en
updated: '2026-08-19T05:19:28+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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**.

```js
// 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 — $n$ wrong fields mean $n+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.

```js
// 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"));
```

```bash
#!/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.

```js
// 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, $n$ wrong fields require $n+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.
