Mapping Validation Errors to Field-Level Messages
Problem: the API rejects a request with 422 and a body saying “Validation failed”. The form shows a red banner, the user has no idea which of eleven inputs is wrong, and support tickets arrive asking what the API means. This guide is part of Designing Robust Error Response Contracts and covers the layer between a validator’s output and something a client can actually place next to an input.
Root Cause: Validator Output Is Not a Contract
Every validation library produces issues in its own shape, with its own codes and its own path representation. Returning that shape directly makes your API’s error contract a function of a dependency — a library upgrade renames a code and every client’s error handling breaks.
Step 1: Publish the Error Item Shape
components:
schemas:
FieldError:
type: object
required: [path, code, message]
additionalProperties: false
properties:
path:
type: string
description: |
Dotted path to the offending value, with bracketed indices for array
elements: `email`, `billing.postcode`, `items[2].unit_price`. An
empty string means the request as a whole rather than one field.
example: items[2].unit_price
code:
type: string
enum: [REQUIRED, INVALID_TYPE, TOO_SMALL, TOO_LARGE, INVALID_FORMAT,
NOT_ALLOWED, UNKNOWN_FIELD, INVALID_VALUE]
description: Stable identifier. Clients branch on this, never on `message`.
message:
type: string
description: |
Default English wording, suitable for logs and for clients with no
translations. May change at any time — it is not part of the contract.
maxLength: 200
ValidationProblem:
allOf:
- $ref: '#/components/schemas/Problem'
unevaluatedProperties: false
required: [errors]
properties:
errors:
type: array
minItems: 1
items: { $ref: '#/components/schemas/FieldError' }
Three decisions are doing the work here. The code enumeration is yours, not the validator’s, so a library upgrade cannot change it. The message is explicitly not contractual, which frees you to improve wording without a version bump. And the path grammar is documented, so a client can parse it rather than reverse-engineer it.
Note the unevaluatedProperties rather than additionalProperties on the composed schema — the composition trap covered in fixing additionalProperties false validation failures.
Step 2: Write the Mapping Layer
One function, used by every route, converting validator output into the published shape.
// errors/from-zod.ts
import { ZodError, ZodIssueCode, type ZodIssue } from "zod";
const CODE_MAP: Partial<Record<ZodIssueCode, string>> = {
invalid_type: "INVALID_TYPE",
too_small: "TOO_SMALL",
too_big: "TOO_LARGE",
invalid_string: "INVALID_FORMAT",
invalid_enum_value: "NOT_ALLOWED",
unrecognized_keys: "UNKNOWN_FIELD",
custom: "INVALID_VALUE",
};
// ["items", 2, "unit_price"] -> "items[2].unit_price"
export function toPath(segments: (string | number)[]): string {
return segments.reduce<string>((acc, segment) => {
if (typeof segment === "number") return `${acc}[${segment}]`;
return acc ? `${acc}.${segment}` : segment;
}, "");
}
export function toFieldErrors(error: ZodError) {
return error.issues.flatMap((issue: ZodIssue) => {
// unrecognized_keys reports one issue listing several keys; split it so
// each unknown field gets its own placeable error.
if (issue.code === "unrecognized_keys") {
return issue.keys.map((key) => ({
path: toPath([...issue.path, key]),
code: "UNKNOWN_FIELD",
message: `${key} is not a recognised field`,
}));
}
// A missing required field arrives as invalid_type with received "undefined".
const code = issue.code === "invalid_type" && issue.received === "undefined"
? "REQUIRED"
: CODE_MAP[issue.code] ?? "INVALID_VALUE";
return [{ path: toPath(issue.path), code, message: issue.message }];
});
}
Two library quirks handled explicitly. A missing required field is reported as a type error against undefined, which is technically accurate and useless to a form — mapping it to REQUIRED is what lets a client show “this field is required”. And unrecognised keys arrive as one issue carrying several key names, so splitting them gives each its own path.
The Ajv equivalent converts a JSON pointer instead of an array:
// errors/from-ajv.ts — "/items/2/unit_price" -> "items[2].unit_price"
export function pointerToPath(pointer: string): string {
return toPath(
pointer.split("/").slice(1)
.map((s) => s.replace(/~1/g, "/").replace(/~0/g, "~")) // JSON pointer unescaping
.map((s) => (/^\d+$/.test(s) ? Number(s) : s)),
);
}
The unescaping is not optional: a property literally named a/b is encoded as a~1b, and a mapper that ignores it produces a path pointing at nothing.
Step 3: Emit Them Inside the Problem Document
// middleware/validate.ts
export function validate(schema: ZodTypeAny): RequestHandler {
return (req, res, next) => {
const parsed = schema.safeParse(req.body);
if (parsed.success) { req.body = parsed.data; return next(); }
res.status(422).type("application/problem+json").json({
type: "https://docs.example.com/errors/validation",
title: "Validation failed",
status: 422,
code: "VALIDATION_FAILED",
detail: `${parsed.error.issues.length} field(s) failed validation.`,
errors: toFieldErrors(parsed.error),
});
};
}
{
"type": "https://docs.example.com/errors/validation",
"title": "Validation failed",
"status": 422,
"code": "VALIDATION_FAILED",
"detail": "3 field(s) failed validation.",
"errors": [
{ "path": "email", "code": "REQUIRED", "message": "Required" },
{ "path": "items[2].unit_price", "code": "TOO_SMALL", "message": "Number must be greater than 0" },
{ "path": "coupon_code", "code": "UNKNOWN_FIELD", "message": "coupon_code is not a recognised field" }
]
}
Every entry maps onto exactly one input, which is the property that makes the response useful. The envelope is the same problem document every other failure uses.
Step 4: Get the Awkward Shapes Right
Three shapes account for most path bugs.
The union case needs the discriminator to be working at all — a bare union reports at the root because the validator genuinely does not know which branch was intended, the failure covered in fixing Zod discriminated union mismatches. The cross-field case needs an explicit path on the issue, as described in transforming and refining data with superRefine.
Verification
test("array element paths use bracket notation", () => {
const result = orderSchema.safeParse({
email: "a@b.com",
items: [ok, ok, { sku: "SKU-1", quantity: 1, unit_price: -5 }],
});
const errors = toFieldErrors(result.error!);
expect(errors).toContainEqual(expect.objectContaining({
path: "items[2].unit_price", code: "TOO_SMALL",
}));
});
test("a missing field reports REQUIRED, not INVALID_TYPE", () => {
const result = orderSchema.safeParse({ items: [ok] });
expect(toFieldErrors(result.error!)).toContainEqual(
expect.objectContaining({ path: "email", code: "REQUIRED" }),
);
});
test("each unknown key gets its own error", () => {
const result = orderSchema.safeParse({ email: "a@b.com", items: [ok],
coupon: "X", referrer: "Y" });
const paths = toFieldErrors(result.error!).map((e) => e.path);
expect(paths).toEqual(expect.arrayContaining(["coupon", "referrer"]));
});
test("every error path resolves to a real value in the payload", () => {
// The property that makes the response usable: a client can find each path.
const payload = { email: "a@b.com", items: [ok, ok, { sku: "S", quantity: 1, unit_price: -5 }] };
for (const err of toFieldErrors(orderSchema.safeParse(payload).error!)) {
if (err.code === "UNKNOWN_FIELD" || err.path === "") continue;
expect(resolvePath(payload, err.path)).toBeDefined();
}
});
test("codes come from our enumeration, not the library's", () => {
const published = new Set(["REQUIRED", "INVALID_TYPE", "TOO_SMALL", "TOO_LARGE",
"INVALID_FORMAT", "NOT_ALLOWED", "UNKNOWN_FIELD", "INVALID_VALUE"]);
for (const err of toFieldErrors(anyFailure())) expect(published.has(err.code)).toBe(true);
});
The last two are the ones worth keeping permanently. One proves every path is resolvable — the property a form depends on — and the other fails the day a library upgrade introduces an unmapped issue code.
Edge Cases and Caveats
- Paths with special characters. A property named
a.bproduces an ambiguous dotted path. Either forbid such names in the schema or document a quoting rule; ambiguity here is silent and unfixable by the client. - Deeply nested paths. A path six levels deep is technically correct and useless to a form. Where nesting is that deep, consider reporting at the level the UI actually renders and putting the detail in
message. - Error count limits. A payload with a thousand invalid array elements produces a thousand errors. Cap the array — a hundred is generous — and say in
detailhow many were omitted. - Localisation. The client localises against
code, so every code must be documented with its meaning. Adding a code is an additive change; changing what an existing code means is breaking. - Security. Never echo the rejected value in
message. A validation error containing a password or a token ends up in logs, in browser consoles and in support tickets.
Keeping the Code Enumeration Small
A code list that grows with every validation rule stops being useful, because a client cannot branch on fifty codes and will fall back to the message. Keep the list closed and short — under a dozen — and let the path carry the specificity. TOO_SMALL on items[2].unit_price is more actionable than a bespoke UNIT_PRICE_BELOW_MINIMUM code, and it does not require a client change when a new field acquires the same rule.
Frequently Asked Questions
What path format should field errors use?
A dotted path with bracketed array indices — items[2].unit_price — because it maps directly onto the form field names most clients already use. Publish the grammar in the contract so a client can parse it rather than guess.
Should the message be localised by the server?
Send a stable code plus a default English message. The code is what a client localises against; the message is a fallback for logs and for clients with no translation. Localising server-side requires the server to know the user’s locale, which it often does not.
How do I report an error that belongs to no single field?
Use a path pointing at the closest containing object, or a documented sentinel such as an empty path meaning the request as a whole. Never omit the path — a client cannot distinguish absent from root.
Should every failure be reported, or just the first?
All of them. A caller fixing four problems one round trip at a time is a bad experience and four times the traffic. Collect every issue in one pass and return them together.
Do field errors belong inside a problem document?
Yes, as an extension member. RFC 9457 explicitly allows extensions, so an errors array alongside type, title and status keeps one envelope for every failure while carrying the per-field detail.