Mapping JSON Schema Errors to Problem Details
This page belongs to Validation Error Payloads, part of the Error Contracts & Resilience Mapping reference. Validators emit errors designed for schema authors: keyword names, internal schema paths and English sentences that change between releases. A public API needs the opposite — a small, stable vocabulary. This page is about the translation layer between them.
The decision trigger
| Symptom | Cause |
|---|---|
| A library upgrade changed your API’s error codes | validator output returned verbatim |
| One bad field produces 14 errors | a failed oneOf cascading through every branch |
Client code does if (msg.includes("must be")) |
no stable code to branch on |
Errors point at /items instead of /items/2/sku |
validator locations not normalised |
A domain rule reports as pattern |
generic keyword used where a domain code belongs |
| Two services describe the same failure differently | no shared mapping table |
Spec snippet: raw output versus contract
// ajv output — the validator's interface, not yours
[
{
"instancePath": "/items/2",
"schemaPath": "#/properties/items/items/required",
"keyword": "required",
"params": { "missingProperty": "sku" },
"message": "must have required property 'sku'"
},
{
"instancePath": "/customer/email",
"schemaPath": "#/properties/customer/properties/email/format",
"keyword": "format",
"params": { "format": "email" },
"message": "must match format \"email\""
}
]
// your contract — stable codes, normalised pointers, no schema internals
{
"type": "urn:api:errors:v1:validation-failed",
"title": "Request validation failed",
"status": 422,
"violations": [
{ "pointer": "/items/2/sku", "code": "required", "message": "This field is required.", "params": {} },
{ "pointer": "/customer/email", "code": "format", "message": "Must be a valid email address.", "params": { "format": "email" } }
]
}
Note what disappeared: schemaPath leaks the internal structure of your schema, including component names and the fact that you use allOf composition, and it changes whenever the schema is refactored even though the rule has not. It has no place in a public error.
Step-by-step
Step 1 — Map keywords to domain codes
// keyword-map.ts — the stable vocabulary your clients branch on
export const CODE_BY_KEYWORD: Record<string, string> = {
required: "required",
type: "type",
format: "format",
pattern: "pattern",
enum: "not_allowed",
const: "not_allowed",
minimum: "min_value",
exclusiveMinimum: "min_value",
maximum: "max_value",
exclusiveMaximum: "max_value",
minLength: "min_length",
maxLength: "max_length",
minItems: "min_items",
maxItems: "max_items",
uniqueItems: "duplicate_items",
additionalProperties: "unknown_field",
oneOf: "variant_unknown",
anyOf: "variant_unknown",
};
export const PARAMS_BY_KEYWORD: Record<string, (p: any) => Record<string, unknown>> = {
minimum: p => ({ min: p.limit }),
maximum: p => ({ max: p.limit }),
minLength: p => ({ min: p.limit }),
maxLength: p => ({ max: p.limit }),
enum: p => ({ allowed: p.allowedValues }),
format: p => ({ format: p.format }),
};
Deliberately collapsing minimum and exclusiveMinimum to one code is a contract decision, not laziness: clients render “must be at least N” either way, and the distinction lives in the parameters.
Step 2 — Let the schema override the code where the rule is domain-specific
# schemas/order.json (excerpt) — annotate the rule with its own code
{
"type": "object",
"properties": {
"currency": {
"type": "string",
"pattern": "^[A-Z]{3}$",
"x-error-code": "unknown_currency"
},
"coupon": {
"type": "string",
"maxLength": 24,
"x-error-code": "coupon_too_long"
}
}
}
A generic pattern code tells the client nothing about which pattern failed. Annotating the schema keeps the code next to the constraint, so refactoring the schema carries the code with it — the same locality argument that makes an error registry maintainable in Building a Machine-Readable Error Registry.
Step 3 — Collapse union noise
A failed oneOf over three variants can produce twenty errors describing shapes the caller never intended. Keep one:
// collapse-union.ts — one violation per failed union, not one per branch
import type { ErrorObject } from "ajv";
export function collapseUnions(errors: ErrorObject[], body: unknown): ErrorObject[] {
const unionPaths = new Set(
errors.filter(e => e.keyword === "oneOf" || e.keyword === "anyOf")
.map(e => e.instancePath),
);
if (unionPaths.size === 0) return errors;
return errors.filter(e => {
// Keep the union error itself…
if (e.keyword === "oneOf" || e.keyword === "anyOf") return true;
// …and any error that is NOT inside a failed union subtree.
return ![...unionPaths].some(
p => e.instancePath.startsWith(p) && e.instancePath !== p,
);
});
}
Where the union has a discriminator and the discriminator value is valid, do the opposite: keep only the errors from the selected branch, because those describe the shape the caller actually meant to send. That refinement depends on knowing the mapping, which is exactly what Discriminator Mapping in Generated Clients documents.
Step 4 — Assemble and deduplicate
// to-violations.ts — the whole translation, in one place
import type { ErrorObject } from "ajv";
import { CODE_BY_KEYWORD, PARAMS_BY_KEYWORD } from "./keyword-map";
import { collapseUnions } from "./collapse-union";
export interface Violation {
pointer: string; code: string; message: string; params: Record<string, unknown>;
}
const escape = (t: string) => t.replace(/~/g, "~0").replace(/\//g, "~1");
export function toViolations(errors: ErrorObject[], body: unknown): Violation[] {
const seen = new Set<string>();
const out: Violation[] = [];
for (const e of collapseUnions(errors, body)) {
let pointer = e.instancePath || "";
if (e.keyword === "required") {
pointer += "/" + escape((e.params as any).missingProperty);
} else if (e.keyword === "additionalProperties") {
pointer += "/" + escape((e.params as any).additionalProperty);
}
// A schema-level x-error-code wins over the keyword mapping.
const code = (e as any).schema?.["x-error-code"]
?? CODE_BY_KEYWORD[e.keyword]
?? "invalid";
const key = `${pointer}|${code}`;
if (seen.has(key)) continue; // one violation per field per rule
seen.add(key);
out.push({
pointer,
code,
message: e.message ?? "Invalid value.",
params: PARAMS_BY_KEYWORD[e.keyword]?.(e.params) ?? {},
});
}
// Deterministic order: clients and snapshot tests both depend on it.
return out.sort((a, b) => a.pointer.localeCompare(b.pointer) || a.code.localeCompare(b.code));
}
Sorting is not cosmetic. Validators do not guarantee error order, so an unsorted array makes snapshot tests flap and makes “the first error” mean something different on each run.
Step 5 — Freeze the mapping with fixtures
// mapping.test.ts — a validator upgrade must not change public codes
import { describe, it, expect } from "vitest";
import { validate, toViolations } from "../src/validation";
const CASES: Array<[string, unknown, Array<[string, string]>]> = [
["missing required", { items: [{}] }, [["/items/0/sku", "required"]]],
["bad format", { customer: { email: "x" } }, [["/customer/email", "format"]]],
["below minimum", { items: [{ sku: "A", quantity: 0 }] }, [["/items/0/quantity", "min_value"]]],
["unknown currency", { currency: "pounds" }, [["/currency", "unknown_currency"]]],
["unknown field", { nope: 1 }, [["/nope", "unknown_field"]]],
];
describe("validator → contract mapping", () => {
for (const [name, body, expected] of CASES) {
it(name, () => {
validate(body);
const got = toViolations(validate.errors ?? [], body).map(v => [v.pointer, v.code]);
expect(got).toEqual(expect.arrayContaining(expected));
});
}
});
The translation layer is a contract boundary, and drawing it explicitly is what makes validator upgrades routine:
Standard compliance
| Concern | Standard | Clause |
|---|---|---|
| Validation keywords | JSON Schema Validation 2020-12 | §6 |
Applicators (oneOf, allOf) |
JSON Schema Core 2020-12 | §10.2 |
| Error location addressing | RFC 6901 | §3–5 |
| Problem body + extensions | RFC 9457 | §3, §3.2 |
| Annotations on schemas | JSON Schema Core 2020-12 | §7.7.1 |
The annotation clause is what makes x-error-code legal rather than a hack: unknown keywords are collected as annotations rather than rejected, so a validator ignores it while your translation layer reads it.
SDK / codegen downstream effect
// Client branching on validation failures
- if (err.message.startsWith("must match format")) markEmailInvalid();
- // breaks when the validator rewords its messages in the next minor release
+ if (v.code === "format" && v.params.format === "email") markEmailInvalid();
+ // stable across validator upgrades; the code is your contract, not theirs
Because the codes are yours, they can also be enumerated in the OpenAPI document as an enum on the code property, which turns them into a generated union type — so a client that switches on the code gets exhaustiveness checking for free.
Common mistakes
| Mistake | Correct approach |
|---|---|
| Returning validator errors verbatim | Translate to a stable code vocabulary you own |
Leaking schemaPath into responses |
Drop it; it exposes internal schema structure |
| One violation per union branch | Collapse to a single violation at the discriminator |
| Non-deterministic error order | Sort by pointer, then code |
Generic pattern for a domain rule |
Annotate the schema with x-error-code |
| No fixtures over the mapping | Freeze it with tests so upgrades cannot change codes |
FAQ
Why not return the validator’s errors directly?
Because they are the validator’s public interface, and it will change them. Keyword names, message wording, params shapes and error ordering all vary between library versions and between languages — ajv, jsonschema, and Go’s validators describe the same failure in three different ways. If those details reach your clients, a routine dependency bump becomes a breaking API change that no diff tool will flag. A translation layer costs perhaps eighty lines and makes your error vocabulary independent of your dependency graph.
How do I stop a failed oneOf from producing dozens of errors?
Collapse the subtree. When a oneOf fails, discard the errors reported inside its branches and emit one violation pointing at the discriminator property with the allowed values as parameters. Those inner errors describe shapes the caller never attempted — telling someone who sent a card payment that iban is missing is worse than useless. The refinement worth adding: if the discriminator value is itself valid, keep the errors from the branch it names and drop the rest, so the caller sees precisely what is wrong with the variant they meant to send.
Should the error code come from the schema or from the mapping table?
Both, with the schema winning. Generic constraints — required, type, maxLength — map perfectly well from the keyword, and a table keeps that consistent across every endpoint without annotation effort. Domain-specific rules need more: a pattern that enforces an ISO currency should report unknown_currency, not pattern, because the client needs to distinguish it from a dozen other pattern rules. Annotate those in the schema with x-error-code so the code lives beside the constraint and survives refactoring, and fall back to the keyword table everywhere else.
Related
- Validation Error Payloads — up-link: the section defining the violations contract
- Error Contracts & Resilience Mapping — up-link: the parent reference for failure contracts
- Field-Level Error Paths with JSON Pointer — the pointer normalisation this pipeline performs
- Building a Machine-Readable Error Registry — where the stable codes are catalogued
- OpenAPI Schema Composition — the composition keywords whose failures produce union noise