The exact problem: the API returns {"errors":[{"pointer":"/data/attributes/billing_address/post_code","detail":"..."}]} and the form rendered an input named billingAddress.postCode. Nothing matches, nothing renders, and the reader sees a submit that fails for no visible reason.

Context and Prerequisites

This is the translation layer inside server error reconciliation, which covers when a 422 is the right branch at all. It assumes the form already normalises errors into the shared shape described in error state mapping patterns — the job here is only to turn a server’s idea of “which field” into the form’s.

Core Pattern: One Translation Function

/** Field names as the form rendered them, e.g. "billingAddress.postCode". */
type CanonicalPath = string;

/**
 * Convert a server-supplied field reference into the name the form used.
 * Kept as ONE function so a renamed API field is a single failing test rather
 * than an error that quietly stops rendering.
 */
export function toCanonicalPath(ref: string, rendered: ReadonlySet<string>): CanonicalPath | null {
  if (!ref) return null;

  // 1. JSON Pointer, with or without an envelope prefix.
  //    "/data/attributes/billing_address/post_code" → ["billing_address","post_code"]
  const segments = ref
    .replace(/^\/?(data\/)?(attributes\/)?/, '')
    .split('/')
    .filter(Boolean)
    // JSON Pointer escaping: ~1 is "/", ~0 is "~". Decode in this order.
    .map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~'));

  // 2. snake_case → camelCase per segment, leaving numeric indices alone.
  const camel = segments.map((s) =>
    /^\d+$/.test(s) ? s : s.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase()));

  // 3. Numeric segments become bracket notation so the result matches the
  //    generated input name: "addresses[1].postCode", not "addresses.1.postCode".
  let path = '';
  for (const seg of camel) {
    path += /^\d+$/.test(seg) ? `[${seg}]` : (path ? `.${seg}` : seg);
  }

  // 4. Only return a path the form actually rendered. Anything else is promoted
  //    to a form-level message by the caller rather than silently dropped.
  return rendered.has(path) ? path : null;
}

The rendered set is what turns a guess into a check. Build it from the names the form actually emitted — not from the schema, which may contain server-only fields — and pass it in. A path that is not in the set is not a translation failure to log and forget; it is an error that must still reach the reader, via the form-level summary.

From a JSON Pointer to the name on the input Stage one strips the response envelope, removing a leading data and attributes prefix. Stage two splits on slashes and decodes JSON Pointer escapes, where tilde one means a slash and tilde zero means a tilde. Stage three converts each snake case segment to camel case while leaving numeric segments alone. Stage four rebuilds the path with numeric segments in bracket notation so the result matches the name a repeated fieldset generated. Finally the result is checked against the set of names the form actually rendered, and anything absent is promoted to a form-level message rather than dropped. /data/attributes/billing_address/post_code 1 · strip envelope drop data/, attributes/ API-specific, not universal 2 · split, unescape ~1 is a slash ~0 is a tilde 3 · snake to camel per segment indices left alone 4 · brackets rows[1].postCode matches the input Then check membership against the names the form actually rendered A path that is not in that set is not dropped — it becomes a form-level message, so the submit never fails invisibly. Build the set from rendered names, not from the schema: the schema contains server-only fields that were never inputs. Every stage above is API-specific. That is the argument for one function, not for a clever general algorithm.

Step-by-Step Walkthrough

  1. Collect the rendered names. As each field registers, add its generated name to a set. This is the ground truth for what can receive an error.

  2. Strip the envelope. JSON:API wraps paths in /data/attributes/; other conventions wrap differently. This step is API-specific and belongs in the one function.

  3. Decode before splitting logic runs. JSON Pointer escapes ~1 for / and ~0 for ~, and decoding in the wrong order corrupts a field whose name legitimately contains a tilde.

  4. Convert case per segment. Whole-string conversion breaks indices and acronyms; per-segment conversion with a numeric guard does not.

  5. Rebuild with bracket notation. The repeated fieldset generated addresses[1].postCode when it rendered; the translation has to produce the same string.

  6. Check, then promote or attach. In the set, attach to that field. Not in the set, promote to the form-level summary with the server’s message.

Failure Modes and Edge Cases

1. The error targets the whole object

A rule spanning fields often arrives with a pointer of "" or /data. That is not a translation failure — it is a genuinely form-level error, and it belongs in the summary. Test for it explicitly rather than letting it fall through the “not in the set” branch, because the two deserve different logging.

2. Indices shifted between submit and response

/addresses/1 refers to a position. If the reader removed a row while the request was in flight, position 1 is now a different address. Map through a stable row id where you have one:

// The row component knows its id and its current index; keep the mapping so a
// positional pointer can be resolved to the row that was actually submitted.
const rowIndexAtSubmit = new Map<string, number>();  // rowId -> index in the payload
const idForIndex = (i: number) =>
  [...rowIndexAtSubmit].find(([, idx]) => idx === i)?.[0] ?? null;

3. The server reports a field the reader cannot see

A conditional field that is currently hidden, or a step of a wizard the reader has not reached, may still be named. Attaching an error to a hidden input renders nothing. Promote it to the summary and make the summary entry navigate — reveal the section, or move to the step — so the entry is actionable rather than merely informative.

4. Several errors, one field

Keep them all in the map and render the first, as elsewhere in the error pipeline. Discarding the rest loses the diagnostic value when somebody asks why a submit failed.

5. The API changes shape without changing status

An API that starts returning field where it used to return pointer will silently stop matching. Fixtures of captured payloads turn that into a failing test the day the API deploys, which is the only reliable defence.

Every path ends somewhere the reader can see A path matching a visible rendered field attaches to that field, and the reader can act on it directly. A path matching a field that exists but is hidden — a collapsed section, or a wizard step not yet reached — attaches to the field and also appears in the summary, where the entry reveals or navigates to it. An object-level pointer, which is empty or points at the root, is a form-level rule and goes straight to the summary. An unrecognised path goes to the summary as well, using the server's own message, and is logged so the mismatch is fixed. No branch discards the error. Translation result Goes to Actionable? a visible rendered field that field yes, directly a hidden or unreached field the field and the summary yes — the entry reveals it an object-level pointer the summary informative unrecognised the summary, plus a log at least visible No branch discards the error, which is the property that makes "the submit failed and nothing appeared" impossible.

Four error-body shapes cover almost every API you will meet, and each needs one line in the adapter:

The four error-body shapes, and the field each carries the path in A JSON:API style body carries an errors array whose entries hold a source object with a pointer. A problem-details body carries an errors object keyed by field name with an array of messages. A flat body carries an errors object mapping field to a single message. A framework-specific body may carry a list of objects with loc arrays. All four are one line each in the adapter, and the point of listing them is that the adapter is the only place that has to know which one this API uses. Body shape Where the path lives Where the message lives JSON:API style errors[].source.pointer errors[].detail problem details the key of errors{} errors[key][0] flat map the key of errors{} errors[key] loc arrays detail[].loc, a segment list detail[].msg Capture one real response of whichever shape your API uses and make it a fixture; the adapter then has a regression test for free.

Verification Checklist


Related

Server Error Reconciliation

Frequently Asked Questions

Should path translation live on the client or the server?

Ideally the server emits the same field names the form rendered, and the whole problem disappears. Where that is not achievable — a shared API, a different naming convention, an envelope you do not control — the translation belongs on the client in exactly one function, tested against captured payloads. What does not work is translating at each call site: the day the API changes, some places update and others silently stop matching.

What should happen to an error the form cannot map?

It goes to the form-level summary with the server’s own message, and it is logged. Dropping it produces the worst failure mode this whole area has — a submit that fails with nothing on screen, which readers report as the button not working. Showing an imperfectly worded message is strictly better than showing nothing.

How do I handle errors on rows of a repeated group?

Translate the numeric segment into bracket notation so it matches the name the row generated, and keep a map from row id to the index that was submitted. If the reader added or removed rows while the request was in flight, resolve through that map rather than trusting the index — otherwise an error lands on whichever row now occupies that position, which is worse than not rendering it at all.