Field-Level Error Paths with JSON Pointer
This page belongs to Validation Error Payloads in the Error Contracts & Resilience Mapping reference. Once you have decided to report every violation, the remaining question is how to name the offending field so a client can put the error next to the input that caused it. RFC 6901 answers it; the details of escaping, array indices and non-body parameters are where implementations go wrong.
The decision trigger
| Symptom | Cause |
|---|---|
| The UI shows a generic error banner instead of a field error | no machine-readable field address |
| An error attaches to the wrong item in a list | array index missing from the path |
A field named unit/price breaks the client’s path parser |
no escaping convention |
| A missing property highlights the whole object | validator reported against the parent |
A bad ?limit= value has no addressable path |
query parameters not covered by the convention |
Two services use items[2].sku and /items/2/sku |
no shared convention |
Spec snippet: pointer syntax at a glance
Document Pointer Refers to
──────────────────────────────── ───────────────── ──────────────────────────
{"customer":{"email":"x"}} /customer/email "x"
{"items":[{"sku":"A"}]} /items/0/sku "A"
{"items":[{"sku":"A"},{}]} /items/1/sku (missing — still addressable)
{"a/b": 1} /a~1b 1
{"a~b": 1} /a~0b 1
{"": 1} / 1 (empty-string key)
{"tags":["x"]} /tags/- the position after the end
whole document "" the root value
Three of these rows are the ones that bite. The escaping rules (~1 for /, ~0 for ~) exist because the separator is a literal character in the path. The empty pointer "" addresses the root, which is what you emit when the violation is about the document as a whole rather than a field. And /- names the position after the last array element, which is useful for “you may not add another item” style rules.
Step-by-step
Step 1 — Escape and unescape correctly
Order matters. Escaping runs ~ → ~0 first, then / → ~1; unescaping must run in the reverse order or a literal ~1 in a field name decodes into a separator:
// pointer.ts — RFC 6901 §3–4
export function escapeToken(token: string): string {
return token.replace(/~/g, "~0").replace(/\//g, "~1"); // ~ first
}
export function unescapeToken(token: string): string {
return token.replace(/~1/g, "/").replace(/~0/g, "~"); // ~1 first
}
export function toPointer(path: (string | number)[]): string {
return path.map(p => "/" + escapeToken(String(p))).join("");
}
export function fromPointer(pointer: string): string[] {
if (pointer === "") return []; // root
if (!pointer.startsWith("/")) throw new Error("not a JSON Pointer");
return pointer.slice(1).split("/").map(unescapeToken);
}
// toPointer(["items", 2, "unit/price"]) === "/items/2/unit~1price"
Step 2 — Point at the child, not the parent
Validators report required against the object that lacks the property. Translating that faithfully produces an error attached to /customer when the user needs it attached to /customer/email:
# ajv_like.py — normalise a validator's location into a usable pointer
def violation_pointer(err: dict) -> str:
"""err has {"keyword": ..., "instancePath": ..., "params": {...}}"""
if err["keyword"] == "required":
missing = err["params"]["missingProperty"]
return f'{err["instancePath"]}/{escape_token(missing)}'
if err["keyword"] == "additionalProperties":
extra = err["params"]["additionalProperty"]
return f'{err["instancePath"]}/{escape_token(extra)}'
return err["instancePath"] or "" # "" is the root, per RFC 6901 §5
additionalProperties has the same problem and the same fix. Both keywords describe something about a child while being reported against the parent.
Step 3 — Decide how non-body parameters are addressed
A JSON Pointer addresses a JSON document; a query string is not one. Two conventions work, and the only wrong answer is leaving it undefined:
{
"type": "urn:api:errors:v1:validation-failed",
"status": 422,
"violations": [
{ "in": "body", "pointer": "/items/2/quantity", "code": "min_value" },
{ "in": "query", "name": "limit", "code": "max_value" },
{ "in": "header","name": "Idempotency-Key", "code": "format" }
]
}
The alternative — a synthetic document with /body/..., /query/limit, /header/Idempotency-Key — keeps a single pointer field at the cost of pointers that do not resolve against the actual request body. Choose one, document it in the schema description, and never mix them.
Step 4 — Bind pointers to form fields
// bind-errors.ts — map pointers onto a form library's field paths
import { fromPointer } from "./pointer";
/** Converts "/items/2/unit~1price" → ["items", 2, "unit/price"] → "items.2.unit/price" */
export function pointerToFieldPath(pointer: string): string {
return fromPointer(pointer)
.map(token => (/^\d+$/.test(token) ? Number(token) : token))
.join(".");
}
export function applyViolations(form: FormApi, violations: Violation[]) {
form.clearErrors();
for (const v of violations) {
if (v.in && v.in !== "body") {
form.setGeneralError(`${v.name}: ${v.message}`); // not a body field
continue;
}
const path = pointerToFieldPath(v.pointer);
if (form.hasField(path)) form.setFieldError(path, v.message);
else form.setGeneralError(v.message); // unknown field: never swallow
}
}
The else branch is the important one. When the server validates a field the form does not render — because the client is older than the API — the error must still surface somewhere, or the user sees a form that refuses to submit with no visible reason.
Two normalisations turn raw validator locations into pointers a form can bind to:
Standard compliance
| Rule | Clause | Detail |
|---|---|---|
| Pointer grammar | RFC 6901 §3 | "" or ("/" reference-token)* |
| Escaping | RFC 6901 §3 | ~0 = ~, ~1 = / |
| Array indices | RFC 6901 §4 | Decimal, no leading zeros; - = end position |
| Root reference | RFC 6901 §5 | The empty string addresses the whole document |
| Error mode | RFC 6901 §4 | Resolving a nonexistent token is an error, not null |
| URI fragment form | RFC 6901 §6 | #/items/2 — percent-encoded; not for JSON bodies |
The last row is a genuine source of confusion: JSON Schema $ref uses the fragment form (#/components/schemas/Order), while validation errors use the plain string form (/items/2/quantity). Emitting the fragment form in an error body means every client strips a leading # that should never have been there.
SDK / codegen downstream effect
// Generated TypeScript
export interface Violation {
- field: string; // "items[2].quantity" — bespoke, needs custom parsing
+ pointer: string; // "/items/2/quantity" — RFC 6901, parseable by any library
code: string;
message: string;
}
The practical gain is that pointer handling becomes library code rather than per-integration code: json-pointer in JavaScript, jsonpointer in Python, github.com/qri-io/jsonpointer in Go all resolve the standard form directly against the request object, so a client can even read the value that failed out of its own payload without string surgery. This is the same “prefer a standard over a convention” argument that drives the shared error envelope in Standardizing Error Responses Across Microservices.
Common mistakes
| Mistake | Correct approach |
|---|---|
items[2].quantity bracket syntax |
RFC 6901 form: /items/2/quantity |
Unescaping ~0 before ~1 |
Unescape ~1 first, then ~0 |
| Pointing at the parent for a missing property | Append the missing property name |
Emitting the #/... fragment form in error bodies |
Use the plain string form |
| Leaving query/header parameters unaddressed | Add an in + name pair, or a documented synthetic path |
| Dropping errors for fields the form does not know | Fall back to a general error message |
FAQ
How do I point at a field whose name contains a slash?
Escape it. RFC 6901 reserves / as the token separator and ~ as the escape character, so inside a reference token a literal ~ is written ~0 and a literal / is written ~1. A property named unit/price inside items[2] is therefore /items/2/unit~1price. When decoding, replace ~1 before ~0 — the reverse order — otherwise a field genuinely named a~1b decodes to a/b and your error attaches to a field that does not exist.
How should a pointer identify a query or header parameter?
It cannot, strictly: a JSON Pointer addresses a JSON document, and neither a query string nor a header block is one. Two conventions work. The first keeps pointer for body fields and adds a sibling pair — "in": "query", "name": "limit" — for everything else, which is honest and maps cleanly onto OpenAPI’s own parameter locations. The second defines a synthetic envelope so every violation has a pointer: /body/items/2/quantity, /query/limit, /header/Idempotency-Key. Either is fine; what breaks clients is discovering both in the same API estate.
Should a pointer for a missing property point at the parent or the child?
At the child that should have been there. Validators report the required keyword against the containing object, because that is where the constraint is declared — so ajv gives you instancePath: "/customer" with params.missingProperty: "email". Emitting that unchanged tells the client “something about the customer object is wrong”, which cannot be bound to an input. Concatenate them into /customer/email during translation. The same applies to additionalProperties, where the offending key is likewise reported against the parent.
Related
- Validation Error Payloads — up-link: the section defining the violations contract
- Error Contracts & Resilience Mapping — up-link: the parent reference for failure contracts
- Mapping JSON Schema Errors to Problem Details — where these pointers are produced from validator output
- Choosing Between 400 and 422 for Validation Errors — the status code that carries them
- Debugging Projection Field Validation Errors — the same addressing problem for query-side field selection