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 autocomplete tokens (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.

Events during a browser address autofill A timeline of one autofill showing six fields receiving input events within a few milliseconds, the country change event arriving last, and a settled validation pass running after the burst. Field input events line1 city postcode phone Country change country Validation pass settled pass (one frame) 0 ms 10 ms 20 ms 30 ms 40 ms 50 ms 60 ms
Autofill fires a burst of events in an order you do not choose; validating once after the burst settles avoids judging the postcode against the old country.

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.

Address lookup writes several fields at once The user picks an address from a lookup list; the lookup script writes the fields, dispatches input events, and one coalesced validation pass runs before focus moves to the next empty field. User Lookup widget Address fields Validator choose "10 Downing St, SW1A 2AA" set line1, city, postcode values dispatch input events (bubbles) schedule one pass rules run, stale errors cleared focus moves to line 2
The lookup writes values and dispatches events exactly like typing would, so the same coalesced pass validates both paths.

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.

Address block after a lookup An address form filled from a lookup, with previously shown errors cleared, the fields left editable and a note that the values came from the lookup. Delivery address Postcode SW1A 2AA 1 Address line 1 10 Downing Street Address line 2 (optional) Town or city London Use this address 1 Lookup writes values then dispatches bubbling input and change events 2 One coalesced pass runs dependent rules and withdraws fixed errors 3 Fields stay editable; the user can correct a lookup result
After a lookup, filled values are ordinary editable inputs; errors they fixed are withdrawn and no new ones appear until blur or submit.

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.

← Back to Phone and Address Validation