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.
Step-by-Step Walkthrough
-
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.
-
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. -
Decode before splitting logic runs. JSON Pointer escapes
~1for/and~0for~, and decoding in the wrong order corrupts a field whose name legitimately contains a tilde. -
Convert case per segment. Whole-string conversion breaks indices and acronyms; per-segment conversion with a numeric guard does not.
-
Rebuild with bracket notation. The repeated fieldset generated
addresses[1].postCodewhen it rendered; the translation has to produce the same string. -
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.
Four error-body shapes cover almost every API you will meet, and each needs one line in the adapter:
Verification Checklist
Related
- Server Error Reconciliation — deciding that a 422 is the right branch
- Clearing Server Errors When a Field Changes — what happens after the error is attached
- Normalizing Nested Field Error Paths — the same problem for schema issue paths
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.