The exact problem: a schema reports an issue at ["addresses", 1, "postcode"], a server reports the same failure at /addresses/1/post_code, and the input was rendered with name="addresses[1].postcode". Three notations for one field, and the message renders under none of them.

Context and Prerequisites

This is the path half of error state mapping patterns, which covers the shape an error lands in. The rule is that exactly one notation is canonical β€” the one the form generated when it rendered β€” and every producer is translated into it at the boundary.

Core Pattern: One Canonical Notation

/** Canonical form: dots for properties, brackets for indices. */
export type Path = string;   // "addresses[1].postcode"

/** From a segment array β€” what schema libraries emit. */
export function fromSegments(segments: readonly (string | number)[]): Path {
  return segments.reduce<string>((acc, seg) => {
    if (typeof seg === 'number') return `${acc}[${seg}]`;
    return acc ? `${acc}.${seg}` : String(seg);
  }, '');
}

/** From a JSON Pointer β€” what most APIs emit. */
export function fromPointer(pointer: string): Path {
  const segs = pointer.replace(/^\//, '').split('/')
    // Decode ~1 before ~0, or a legitimate "~1" in a key is corrupted.
    .map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~'))
    .map((s) => (/^\d+$/.test(s) ? Number(s) : s));
  return fromSegments(segs);
}

/** Back to segments β€” needed to read or write a value at that path. */
export function toSegments(path: Path): (string | number)[] {
  return path.split('.').flatMap((part) => {
    const [head, ...idx] = part.split('[');
    return [head, ...idx.map((i) => Number(i.replace(']', '')))];
  }).filter((s) => s !== '');
}

The generator is the other half. The form must emit the canonical notation when it renders, because a translation that produces a correct string nothing was rendered with is still a message that does not appear:

// The repeated fieldset knows its own path prefix and its index, so the name
// it renders and the name the resolver looks for are produced by one function.
const fieldName = (prefix: Path, index: number, key: string) =>
  fromSegments([...toSegments(prefix), index, key]);
Three notations in, one notation out A schema library emits an array of segments mixing strings and numbers. A server emits a JSON Pointer string with slash separators and escape sequences. The rendered input carries a name attribute in the form's own notation. All three are converted into one canonical path using dots for properties and brackets for indices, and that single notation is what the error map is keyed by, what the summary links resolve against, and what the first-invalid focus routine matches on. schema issue.path ["addresses", 1, "postcode"] server pointer /addresses/1/post_code rendered input name addresses[1].postcode canonical path addresses[1].postcode the error map key one entry per field summary links resolve to an id first-invalid focus matches by name The four segment kinds, and how each is written A plain property is joined with a dot, unless it is the first segment, in which case it stands alone. An array index is written in brackets and attached directly to the preceding segment with no dot. A property following an index is joined with a dot as usual. And the root, an empty segment list, produces an empty string, which is the canonical way to say the whole object rather than a missing path. Segment kind Written as Example the first property bare addresses a later property preceded by a dot .postcode an array index brackets, no dot [1] the root an empty string "" The empty string is deliberate: it is a real value meaning "the whole object", distinct from a path that failed to resolve.

Step-by-Step Walkthrough

  1. Pick the notation the DOM can carry. Dots and brackets survive being a name attribute and an id; a segment array does not.

  2. Generate names from the same function that parses them. If fieldName and toSegments are inverses, a rename cannot desynchronise them.

  3. Translate at the boundary, once. One adapter per producer, each tested against captured payloads.

  4. Keep indices numeric through the conversion. "1" and 1 behave differently the moment you use the segments to read a value.

  5. Decode pointer escapes in the right order. ~1 before ~0, or a key legitimately containing ~1 is corrupted.

  6. Fall back visibly. An unrecognised path goes to the form-level summary, never to a log alone.

Failure Modes and Edge Cases

1. Positions versus identities

addresses[1] is a position. Delete row 0 and the same string now names a different address. Where rows can be reordered or removed, key the render by a stable id and translate positions through the ordering captured at submit.

2. Keys containing dots

A field literally named user.name is ambiguous in dotted notation. Either forbid it, or escape it consistently in both directions β€” and test the round trip, because half-implemented escaping is worse than none.

3. Root-level issues

An empty segment array or a pointer of "" means the whole object. That is a form-level error and should be routed as one, not treated as a translation failure.

4. Sparse arrays

An issue at index 3 of an array rendered with two rows resolves to nothing. Promote it rather than dropping it β€” it usually means the client and server disagree about the payload, which is worth surfacing.

5. Two producers, one field

A schema issue and a server error for the same canonical path both belong in the map. Keep both and render by precedence, as error state mapping patterns describes.

The round trip that has to hold The form generates a name from a prefix, an index and a key. That string is what the input carries and what a summary link resolves against. A producer later reports a path in its own notation, which the adapter converts into the same string. Parsing that string back into segments must return exactly what generated it β€” otherwise reading or writing the value at that path lands somewhere else, and the message renders on the wrong field. The round trip that has to hold generate prefix + index + key becomes the input name report a producer emits its own notation convert the adapter produces the same string parse back segments must equal what generated it Assert that round trip over every path shape the form can produce; it is a three-line test and it catches every notation bug.

Verification Checklist

Common Pitfalls

  • Two notations in the codebase. The moment a second one appears, some producers resolve and others silently do not, and the difference only shows up as a missing message on one particular field shape.
  • Hand-writing input names. A generated name and a hand-written resolver key drift the first time a schema field is renamed, and nothing fails β€” the message simply stops appearing.
  • Treating indices as identities. A path names a position in the payload that was submitted. Delete a row while a request is in flight and the same path now points at a different row.
  • Decoding pointer escapes in the wrong order. Replacing ~0 before ~1 corrupts any key that legitimately contains a tilde followed by a one, which is rare and extremely hard to trace.
  • Dropping unresolvable paths. A submit that fails with nothing on screen is the worst outcome in this whole area. Route anything that does not resolve to the form-level summary.

Related

← Error State Mapping Patterns

Frequently Asked Questions

Why dots and brackets rather than a segment array everywhere?

Because the DOM can only carry a string. The name attribute, the id, the summary link’s href and the focus routine’s selector all need one, so the notation that survives being written into markup is the one worth making canonical. Segment arrays are better inside code, which is why the pair of conversion functions exists β€” use arrays when you need to walk a value, strings everywhere the DOM is involved.

How do I handle a field whose key contains a dot?

Forbid it if you can β€” a key like user.name is ambiguous in every dotted notation and every consumer has to agree on the escaping. If it cannot be avoided, escape consistently in both directions and add a round-trip test for it, because a half-implemented escape scheme corrupts paths in a way that is very hard to trace back from a missing message.

What happens when rows are reordered between render and response?

The index in the path refers to a position in the payload that was submitted, not to whatever now occupies that position. Capture the row ids in submit order, and resolve incoming indices through that capture. Without it, deleting a row while a request is in flight moves an error onto an unrelated row, which is worse than not rendering it β€” the reader edits something that was never wrong.