Validating Autocompleted Address Fields
Why does an address form that validates perfectly when typed show stale errors, skip rules, or submit with a mismatched postcode when the browser autofills it or when a user picks a result from an address lookup? Because both paths change several field values at once, in an order you do not control, and not always with the events your handlers listen for. This recipe detects fills reliably, re-runs every dependent rule once the fill settles, clears errors that the new values fix, and keeps lookup-selected addresses editable and validated — all on top of the standard <form novalidate> plus Constraint Validation API baseline.
When to Use Fill-Aware Validation
You need this whenever an address form has rules that depend on more than one field, which in practice is every international address form: the postcode rule depends on the country, the state list depends on the country, and a “same as shipping” checkbox copies a whole block. Use this recipe when:
- Fields carry
autocompletetokens (and they should, for WCAG 1.3.5), so browsers fill them in bulk. - You offer an address lookup that writes several fields from one selection.
- Errors can be showing when the fill happens, typically after a failed submit, where stale messages next to freshly filled, valid values are especially confusing.
The dependent-rule logic itself — postcode by country, phone by country — is covered in postal code validation by country. This page is about making sure those rules actually run when values arrive by fill rather than by typing.
Minimal Working Fill-Aware Validator
type Rule = (form: HTMLFormElement) => void;
/** Rules that depend on several fields, re-run as one pass. */
const DEPENDENT_RULES: Rule[] = [
(f) => {
const country = (f.elements.namedItem("country") as HTMLSelectElement).value;
const postcode = f.elements.namedItem("postcode") as HTMLInputElement;
postcode.setCustomValidity(postcodeError(postcode.value, country, postcode.required));
},
(f) => {
const country = (f.elements.namedItem("country") as HTMLSelectElement).value;
const phone = f.elements.namedItem("phone") as HTMLInputElement;
phone.setCustomValidity(checkPhone(phone.value, country as CountryCode).error);
},
];
export function fillAwareValidation(form: HTMLFormElement): void {
let scheduled = false;
const runAll = () => {
scheduled = false;
for (const rule of DEPENDENT_RULES) rule(form);
// Withdraw visible errors that the new values fixed; never add new ones mid-fill.
for (const el of form.querySelectorAll<HTMLInputElement | HTMLSelectElement>("[aria-invalid=true]")) {
if (el.checkValidity()) clearError(el);
}
};
// Coalesce a burst of input/change events into one pass on the next frame.
const schedule = () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(runAll);
};
form.addEventListener("input", schedule);
form.addEventListener("change", schedule);
// Chromium and Safari apply :autofill; animation start is a dependable fill signal.
form.addEventListener("animationstart", (event) => {
if ((event as AnimationEvent).animationName === "on-autofill") schedule();
});
form.addEventListener("submit", (event) => {
runAll();
if (!form.checkValidity()) {
event.preventDefault();
form.reportValidity();
}
});
}
function clearError(el: HTMLInputElement | HTMLSelectElement): void {
el.removeAttribute("aria-invalid");
const errId = el.getAttribute("aria-describedby")?.split(" ").find((id) => id.endsWith("-err"));
if (errId) document.getElementById(errId)?.setAttribute("hidden", "");
}
/* A no-op animation used purely as an autofill detector. */
@keyframes on-autofill { from { opacity: 1; } to { opacity: 1; } }
input:autofill, select:autofill { animation: on-autofill 1ms; }
Coalescing matters more than any single event. Autofill in Chromium dispatches input and change per field in document order, then the country select changes — often after the postcode. Validating each field as its event arrives judges the postcode against the previous country and flashes a false error. Scheduling one pass on the next animation frame lets the whole burst land first.
Fill-Aware Validator Option Reference
| Option | Type | Default | Purpose |
|---|---|---|---|
DEPENDENT_RULES |
Rule[] |
postcode, phone | Multi-field rules re-run on every pass |
| Scheduling | requestAnimationFrame |
one frame | Coalesces a fill burst into one pass |
| Autofill signal | animationstart on :autofill |
on | Catches fills that emit no input event |
| Error withdrawal | on pass | enabled | Hides messages the new values fixed |
| Error creation | on blur/submit only | — | A fill never adds a new visible error |
| Lookup event dispatch | new Event("input", { bubbles: true }) |
required | Makes scripted writes look like typing |
When your own code writes values — an address lookup, a “same as shipping” checkbox — always dispatch a bubbling input event afterwards. Setting .value from script fires nothing, which is the root cause of most “validation didn’t run” bugs; the validating on blur versus on input guide covers the same trap for single fields.
function writeField(el: HTMLInputElement | HTMLSelectElement, value: string): void {
el.value = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
Verification Steps
import { test, expect } from "@playwright/test";
test("scripted fill clears stale errors", async ({ page }) => {
await page.goto("/checkout/address");
await page.getByRole("button", { name: "Continue" }).click(); // show errors
await expect(page.getByLabel("Postcode")).toHaveAttribute("aria-invalid", "true");
await page.evaluate(() => {
const set = (n: string, v: string) => {
const el = document.querySelector<HTMLInputElement>(`[name=${n}]`)!;
el.value = v;
el.dispatchEvent(new Event("input", { bubbles: true }));
};
set("line1", "10 Downing Street"); set("city", "London"); set("postcode", "SW1A 2AA");
});
await expect(page.getByLabel("Postcode")).not.toHaveAttribute("aria-invalid", "true");
});
Edge Cases and Failure Modes
Firefox fills without :autofill animation hooks in some versions. It does dispatch input, so the coalesced input listener covers it; the animation trick is a supplement for engines that fill silently, not the primary signal.
Autofill into hidden fields. Browsers may fill fields hidden with CSS but not those with the hidden attribute or display: none in some versions. A hidden “state” field for a country that does not need one can end up holding a stale value that fails validation invisibly. Disable fields you hide; disabled fields are skipped by validation and not submitted.
Country select values that do not match. Autofill matches the stored country against option values or text. Use ISO 3166 alpha-2 codes as values and localised names as text, so both kinds of matching succeed.
Lookups that overwrite user edits. If the user types line 2 and then picks a lookup result, do not wipe line 2 unless the lookup returned one. Write only the fields the result contains.
Making “Same as Delivery Address” Validate Correctly
The billing-address checkbox is a scripted fill in disguise, and it has one extra state to manage: while ticked, the billing fields mirror the delivery fields and should be neither editable nor independently validated; when unticked, they become ordinary fields with their own rules. The cleanest implementation disables the billing fieldset while the box is ticked — disabled controls are excluded from constraint validation and from submission — and sends a single billing_same=on flag to the server instead of duplicate values.
const same = document.querySelector<HTMLInputElement>("#billing-same")!;
const billing = document.querySelector<HTMLFieldSetElement>("#billing")!;
same.addEventListener("change", () => {
billing.disabled = same.checked; // skips validation for every control inside
billing.hidden = same.checked;
if (!same.checked) billing.querySelector<HTMLInputElement>("input")?.focus();
});
Copying the delivery values into the billing inputs instead — the approach many forms take — doubles the validation work, sends redundant data, and leaves the two blocks free to drift if the user later edits the delivery address. The disabled-fieldset approach avoids all three and still satisfies the redundant-entry requirement.
Showing New Errors After a Fill
The recipe deliberately never adds a visible error during a fill — it only withdraws ones the fill fixed. That follows the “reward early, punish late” principle from best practices for inline validation timing: a user who just accepted an autofill suggestion has not had a chance to review it, and a sudden red message on a field they did not touch reads as the form’s fault. The rules still run, so the custom validity is set and the next submit or blur reports it with focus. If your product needs faster feedback — say a lookup returned an address you cannot deliver to — show that as a single, form-level notice next to the lookup, phrased as information (“We can’t deliver to this postcode yet”), rather than as field errors scattered across the block.
Frequently Asked Questions
Does browser autofill fire input events?
Usually, but not consistently across engines and versions, and the order of events across fields is not guaranteed. Listen for both input and change, and use an :autofill animation hook as a backup signal.
Why does my postcode show an error right after autofill?
It was validated before the country field changed. Coalesce the burst of events and run multi-field rules once, on the next animation frame, after all values have landed.
Why doesn't validation run when my script sets a field value?
Assigning .value from script dispatches no events. After writing a value, dispatch a bubbling input event (and change for selects) so your handlers run exactly as they would for typing.
Should autofill trigger new error messages?
No. Run the rules and withdraw errors that the new values fixed, but only show new errors on blur or submit, when the user has had a chance to review the filled values.
Related Guides
- Phone and Address Validation — the country-first rules this recipe triggers.
- Postal Code Validation by Country — the postcode rule table.
- Autocomplete Attribute and Validation — choosing tokens so fills land in the right fields.
- WCAG 3.3.7 Redundant Entry Checklist — “same as shipping” and other ways to avoid re-entry.