Validating Nested Object Fields
Forms are flat — a list of inputs with names — but the data they collect usually is not: a customer has a billing address and a shipping address, each with a street, city and postcode; a company has a contact with a name and a phone; an order has lines that each have a product and options. How do you validate that nested shape with one schema, get error paths that point at the exact input (shipping.address.postcode), show group-level messages (“Billing address is incomplete”), and still let the browser’s Constraint Validation API focus and announce the first problem? This recipe uses dotted input names as the bridge: a small converter turns FormData into a nested object, a nested schema validates it, and issue paths joined with dots are exactly the input names, so errors map back with form.elements.namedItem() and no translation table.
When to Use Nested Names
Use dotted names whenever the data you submit has structure that matters to the server or to validation rules:
- Repeated sub-structures — two addresses, several contacts — where the same field name (
city) appears in more than one group. - Cross-field rules inside a group — “postcode must match the country” — that should report on the group’s field, not a global one.
- Shared sub-schemas — an
Addressschema reused for billing, shipping and company address.
For flat forms with a handful of unique fields, plain names are simpler. For groups that the user adds and removes at runtime, combine this recipe with validating dynamically added form rows. The broader topic is dynamic and repeating fields.
Minimal Working Nested Validation
<form id="checkout" novalidate>
<fieldset id="billing" aria-describedby="billing-err">
<legend>Billing address</legend>
<label for="billing-line1">Address line 1</label>
<input id="billing-line1" name="billing.line1" autocomplete="billing address-line1" required>
<label for="billing-city">Town or city</label>
<input id="billing-city" name="billing.city" autocomplete="billing address-level2" required>
<label for="billing-postcode">Postcode</label>
<input id="billing-postcode" name="billing.postcode" autocomplete="billing postal-code">
<label for="billing-country">Country</label>
<select id="billing-country" name="billing.country" autocomplete="billing country" required>…</select>
<p id="billing-err" class="field-error" tabindex="-1" hidden></p>
</fieldset>
<label><input type="checkbox" name="sameAsBilling" checked> Ship to my billing address</label>
<fieldset id="shipping" aria-describedby="shipping-err" disabled hidden>
<legend>Delivery address</legend>
<!-- same fields with name="shipping.*" and autocomplete="shipping …" -->
<p id="shipping-err" class="field-error" tabindex="-1" hidden></p>
</fieldset>
<button type="submit">Continue</button>
</form>
import { z } from "zod";
/** "billing.city" → { billing: { city } }; guards against prototype pollution. */
export function toNested(fd: FormData): Record<string, unknown> {
const out: Record<string, any> = Object.create(null);
for (const [name, value] of fd) {
const path = name.split(".");
if (path.some((p) => p === "__proto__" || p === "constructor" || p === "prototype")) continue;
let node = out;
path.slice(0, -1).forEach((p) => (node = node[p] ??= Object.create(null)));
node[path[path.length - 1]] = value;
}
return out;
}
const Address = z
.object({
line1: z.string().trim().min(1, "Enter the first line of the address."),
city: z.string().trim().min(1, "Enter a town or city."),
postcode: z.string().trim(),
country: z.string().length(2, "Choose a country."),
})
.superRefine((a, ctx) => {
// A rule inside the group reports on the group's own field.
if (a.country === "GB" && !/^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$/i.test(a.postcode)) {
ctx.addIssue({ code: "custom", path: ["postcode"], message: "Enter a postcode like SW1A 1AA." });
}
});
export const Checkout = z
.object({
billing: Address,
sameAsBilling: z.literal("on").optional(),
shipping: Address.optional(),
})
.superRefine((d, ctx) => {
if (!d.sameAsBilling && !d.shipping) {
ctx.addIssue({ code: "custom", path: ["shipping"], message: "Enter a delivery address or tick “Ship to my billing address”." });
}
});
// Apply issues: leaf paths go to inputs, group paths go to the group's message.
function applyIssues(form: HTMLFormElement, issues: z.ZodIssue[]): void {
for (const el of form.querySelectorAll<HTMLInputElement | HTMLSelectElement>("[name]")) el.setCustomValidity("");
for (const g of form.querySelectorAll<HTMLElement>("fieldset > .field-error")) { g.hidden = true; g.textContent = ""; }
for (const issue of issues) {
const name = issue.path.join(".");
const control = form.elements.namedItem(name);
if (control instanceof HTMLInputElement || control instanceof HTMLSelectElement) {
if (!control.validationMessage) control.setCustomValidity(issue.message); // first message wins
continue;
}
const groupMsg = document.getElementById(`${name}-err`); // e.g. "shipping-err"
if (groupMsg) { groupMsg.textContent = issue.message; groupMsg.hidden = false; }
}
}
const form = document.querySelector<HTMLFormElement>("#checkout")!;
const shipping = form.querySelector<HTMLFieldSetElement>("#shipping")!;
form.querySelector<HTMLInputElement>("[name=sameAsBilling]")!.addEventListener("change", (e) => {
shipping.disabled = shipping.hidden = (e.target as HTMLInputElement).checked; // disabled groups aren't submitted
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const result = Checkout.safeParse(toNested(new FormData(form)));
applyIssues(form, result.success ? [] : result.error.issues);
const fieldsOk = form.reportValidity();
const groupError = form.querySelector<HTMLElement>("fieldset > .field-error:not([hidden])");
if (fieldsOk && groupError) groupError.focus(); // group-only problems need manual focus
if (fieldsOk && !groupError) form.submit();
});
Disabling the shipping fieldset while “same as billing” is ticked does double duty: disabled controls are excluded from FormData, so shipping is simply absent from the nested object (and Address.optional() accepts that), and they are excluded from constraint validation, so their required attributes cannot block submission.
Nested Validation Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
| Input names | dotted paths | group.field |
Build nested data and find inputs from issue paths |
toNested(fd) |
function | — | FormData → nested object, prototype-safe |
| Reusable sub-schema | z.object |
Address |
Same rules for every address group |
| Group rules | superRefine with path |
— | Report on the right field inside the group |
| Group message id | ${groupName}-err |
— | Target for issues whose path is a group |
| Disabled fieldset | attribute | when not applicable | Removes a whole group from validation and data |
autocomplete sections |
billing … / shipping … |
— | Keeps autofill from mixing the two addresses |
Verification Steps
import { describe, it, expect } from "vitest";
import { toNested, Checkout } from "./checkout";
const fd = (entries: Record<string, string>) => {
const f = new FormData();
Object.entries(entries).forEach(([k, v]) => f.append(k, v));
return f;
};
describe("nested checkout", () => {
it("builds nested objects from dotted names", () => {
expect(toNested(fd({ "billing.city": "London", "billing.country": "GB" }))).toEqual({ billing: { city: "London", country: "GB" } });
});
it("reports a nested postcode issue with a full path", () => {
const r = Checkout.safeParse(toNested(fd({ "billing.line1": "10 Downing St", "billing.city": "London", "billing.postcode": "123", "billing.country": "GB", sameAsBilling: "on" })));
expect(!r.success && r.error.issues.map((i) => i.path.join("."))).toEqual(["billing.postcode"]);
});
it("ignores prototype-polluting names", () => {
expect(({} as any).admin).toBeUndefined();
toNested(fd({ "__proto__.admin": "1" }));
expect(({} as any).admin).toBeUndefined();
});
});
Edge Cases and Failure Modes
Group-level issues with no input. An issue whose path is ["shipping"] has no control to focus. Give each group a focusable message element and focus it when no field error took focus, as the submit handler does.
Mixed arrays and objects. Names like lines.0.qty are ambiguous — is 0 an array index or an object key? This converter always creates objects; if your schema expects arrays, either convert numeric keys to arrays after parsing or model the list as a record keyed by id, which is what validating dynamically added form rows recommends.
Autofill mixing groups. Without section-scoped tokens, a browser may fill the shipping postcode with the billing one. Prefix autocomplete tokens with billing and shipping, which is also what WCAG 1.3.5 expects for identifiable purposes.
Names with dots in their keys. If a real key contains a dot (rare, but it happens with imported data), dotted paths become ambiguous. Choose a different separator for those forms, or escape dots consistently on both sides.
Keeping Group Messages and Field Messages Consistent
Group-level and field-level messages must not contradict or duplicate each other. When the whole shipping section is missing, one group message (“Enter a delivery address or tick …”) is clearer than six “Enter a …” messages on every empty field — which is why the schema reports on the group when the entire shipping object is absent, and on individual fields when it is present but incomplete. When individual field errors exist inside a group, suppress the group message, or reduce it to a count (“2 fields in the delivery address need attention”) for the error summary. Decide the rule once, encode it in the schema’s superRefine, and the rendering code stays simple. The same tension between one summary and many field messages is discussed in error summary vs inline errors.
Nested Errors From the Server
Server responses for nested forms use the same paths. An API that returns problem details with JSON Pointers — /billing/postcode — converts to the same dotted names by replacing slashes with dots, and applyIssues handles them unchanged. Group-level server errors (for example “we can’t deliver to this address”) come back pointing at /shipping, and land in the group message. Keeping one mapping function for both client- and server-originated issues is what makes the round trip consistent; the server side is covered in validating FormData on the server with Zod and the pointer format in problem details (RFC 9457) for field errors.
Frequently Asked Questions
How do I validate nested objects from an HTML form?
Give inputs dotted names such as billing.city, convert FormData into a nested object by splitting names on dots, validate with a nested schema, and join each issue's path with dots to find the input again.
How do I show an error for a whole group of fields?
Give the group's fieldset a focusable message element referenced by aria-describedby, route issues whose path points at the group to it, and focus it when no individual field error took focus.
How do I skip validation of an optional nested section?
Put the section in a fieldset and set disabled on it when it does not apply. Disabled controls are excluded from constraint validation and from FormData, so an optional sub-schema sees the section as absent.
Is splitting FormData names on dots safe?
Only if you guard against prototype pollution: skip path segments named __proto__, constructor or prototype, and build objects with Object.create(null).
Related Guides
- Dynamic and Repeating Fields — structure that changes at runtime.
- Using Zod for Complex Form Schemas — composing nested schemas.
- Validating Autocompleted Address Fields — the billing and shipping groups in practice.
- Validating Radio Groups and Fieldsets — group-level validation in the native model.