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.

Resolving a JSON Pointer token by token The pointer slash items slash 2 slash quantity is split into three tokens. The first selects the items array from the root object, the second selects index two, and the third selects the quantity property of that element. /items/2/quantity token"items" token "2" token "quantity" { "order_id": "o_9", "items": [ … ] } [ {sku:"A"}, {sku:"B"}, {sku:"C", quantity:0} ] { "sku": "C", "quantity": 0 } ← index 2 0 ← the offending value

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.

Binding a pointer to a form field A pointer is split into tokens, unescaped, numeric tokens are converted to indices, and the resulting path is looked up in the form. A match highlights the field; a miss falls back to a general error message so nothing is swallowed. /items/2/unit~1price violation.pointer split + unescape [items, 2,unit/price] form path items.2.unit/price field exists highlight it inline field unknown show as a general error never silently discard

Two normalisations turn raw validator locations into pointers a form can bind to:

Normalising validator locations into usable pointers Required and additionalProperties keywords are reported against the parent object, so the missing or extra property name must be appended. Array indices and escaped tokens pass through unchanged. a pointer at the parent cannot be bound to an input required reported at /customer append → /customer/email additionalProperties reported at / append → /unexpected_field type, format, minimum reported at the value pass through unchanged keys containing / or ~ escaped as ~1 and ~0 unescape ~1 before ~0 decoding in the wrong order turns a field named a~1b into a path separator

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.