Phone and Address Validation

Phone numbers and postal addresses are the fields where over-strict validation turns away the most real customers. A regular expression written for ten-digit North American numbers rejects every caller in the United Kingdom; a “required” state field blocks everyone in Singapore; a postal-code pattern that assumes five digits refuses Canadian, Dutch and British codes; a “street must contain a number” rule fails rural addresses in Ireland that have no house numbers at all. Each of these rules was written with good intentions, and each silently loses orders. This topic shows how to validate these fields just enough — normalising what can be normalised, checking what can be checked per country, and deferring everything else to the services that actually deliver parcels and route calls. Everything sits on the site’s standard <form novalidate> plus Constraint Validation API baseline.

The guiding principle is asymmetric risk. Accepting a slightly malformed phone number costs a failed SMS that can be retried; rejecting a valid one costs the customer. So the validators here reject only what is certainly wrong, warn about what is probably wrong, and accept everything else.

Layers of phone and address validation Four layers from structural markup with autocomplete tokens, through normalisation, per-country rules, to verification by an external service after submission. Structure and autocomplete tel, postal-code, address-line1, country tokens Normalisation E.164 for phones, uppercase and spacing for postcodes Per-country rules libphonenumber metadata, postcode patterns by country Verification after submit SMS code, address lookup, carrier validation
The browser handles the first three layers; delivery and reachability can only be confirmed by sending something.

Prerequisites for Phone and Address Fields

Requirement Minimum version Why it is needed
TypeScript 5.0+ Typed country metadata tables
libphonenumber-js 1.10+ (/min or /max metadata) Country-aware parsing, formatting and validity
type="tel" + autocomplete="tel" All browsers Phone keypad and autofill without numeric coercion
autocomplete address tokens All browsers street-address, address-line1, address-level2, postal-code, country
Intl.DisplayNames Chrome 81, Firefox 86, Safari 14.1 Localised country names in the selector
Country-first field order Postcode and phone rules depend on the country

Phone and Address API Reference

API / attribute Type Effect Notes
type="tel" attribute Phone keypad on mobile No built-in format validation, by design
autocomplete="tel" token Full international number Also tel-national, tel-country-code
parsePhoneNumberFromString(v, cc) (string, CountryCode) => PhoneNumber | undefined Parses with a default country Returns undefined on garbage
phone.isValid() () => boolean Full metadata check Needs max metadata for precision
phone.isPossible() () => boolean Length-only check Cheaper, fewer false negatives
phone.number string (E.164) +442079460958 Store and submit this form
autocomplete="postal-code" token Postcode autofill Pair with country token on the selector
Intl.DisplayNames(locale, { type: "region" }) constructor Country names Keeps the list localised without a data file

Step-by-Step Implementation

1. Put the country first

Almost every rule on this page depends on the country. Asking for it first — or defaulting it from the shipping destination, the site’s locale, or navigator.language — means every later field can be validated and formatted correctly as the user types.

<form id="address" novalidate>
  <label for="country">Country or region</label>
  <select id="country" name="country" autocomplete="country" required></select>

  <label for="line1">Address line 1</label>
  <input id="line1" name="line1" autocomplete="address-line1" required>

  <label for="line2">Address line 2 <span class="optional">(optional)</span></label>
  <input id="line2" name="line2" autocomplete="address-line2">

  <label for="city">Town or city</label>
  <input id="city" name="city" autocomplete="address-level2" required>

  <label for="postcode">Postcode</label>
  <input id="postcode" name="postcode" autocomplete="postal-code" aria-describedby="postcode-hint postcode-err">
  <p id="postcode-hint" class="hint"></p>
  <p id="postcode-err" class="field-error" hidden></p>

  <label for="phone">Phone number</label>
  <input id="phone" name="phone" type="tel" autocomplete="tel" aria-describedby="phone-hint phone-err">
  <p id="phone-hint" class="hint">We only call about delivery problems.</p>
  <p id="phone-err" class="field-error" hidden></p>
</form>

2. Populate the country list from Intl

const COUNTRIES = ["GB", "IE", "US", "CA", "DE", "FR", "NL", "SG", "AU", "JP"] as const;
type Country = (typeof COUNTRIES)[number];

const names = new Intl.DisplayNames([navigator.language], { type: "region" });
const select = document.querySelector<HTMLSelectElement>("#country")!;
select.replaceChildren(
  ...COUNTRIES.map((code) => new Option(names.of(code) ?? code, code))
    .sort((a, b) => a.text.localeCompare(b.text)),
);
select.value = (navigator.language.split("-")[1] as Country) ?? "GB";

3. Validate the phone number against the selected country

import { parsePhoneNumberFromString, type CountryCode } from "libphonenumber-js/max";

export function phoneError(raw: string, country: CountryCode, required: boolean): string {
  if (raw.trim() === "") return required ? "Enter a phone number." : "";
  const phone = parsePhoneNumberFromString(raw, country);
  if (!phone) return "Enter a phone number using only digits, spaces and +.";
  if (!phone.isPossible()) return "This phone number has the wrong number of digits.";
  if (!phone.isValid()) return `This doesn't look like a valid ${names.of(phone.country ?? country)} phone number.`;
  return "";
}

isPossible() checks length only and is almost never wrong; isValid() also checks number ranges against metadata and can lag new allocations by a release. The full recipe, including E.164 storage and formatting as the user types, is international phone validation with libphonenumber.

4. Validate the postcode by country — or not at all

const POSTCODE: Partial<Record<Country, { re: RegExp; example: string; label: string }>> = {
  GB: { re: /^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$/i, example: "SW1A 1AA", label: "Postcode" },
  US: { re: /^\d{5}(-\d{4})?$/, example: "94103 or 94103-1234", label: "ZIP code" },
  CA: { re: /^[ABCEGHJ-NPRSTVXY]\d[ABCEGHJ-NPRSTV-Z] ?\d[ABCEGHJ-NPRSTV-Z]\d$/i, example: "K1A 0B1", label: "Postal code" },
  DE: { re: /^\d{5}$/, example: "10115", label: "Postleitzahl" },
  NL: { re: /^\d{4} ?[A-Z]{2}$/i, example: "1012 AB", label: "Postcode" },
  IE: { re: /^[A-Z\d]{3} ?[A-Z\d]{4}$/i, example: "D02 X285", label: "Eircode" },
};

export function postcodeError(raw: string, country: Country): string {
  const rule = POSTCODE[country];
  if (!rule) return ""; // no rule, or country without postcodes: accept
  const v = raw.trim().toUpperCase();
  if (v === "") return `Enter your ${rule.label.toLowerCase()}.`;
  return rule.re.test(v) ? "" : `Enter a ${rule.label.toLowerCase()} like ${rule.example}.`;
}

Countries without a rule in the table are accepted as typed. That includes countries without postcodes at all, such as Hong Kong and several Gulf states, where the field should be hidden or optional rather than required. The detailed recipe, with label changes per country, is postal code validation by country.

5. Re-run dependent validation when the country changes

const phone = document.querySelector<HTMLInputElement>("#phone")!;
const postcode = document.querySelector<HTMLInputElement>("#postcode")!;

function syncCountry(): void {
  const c = select.value as Country;
  const rule = POSTCODE[c];
  document.querySelector("label[for=postcode]")!.textContent = rule?.label ?? "Postcode (if you have one)";
  document.querySelector("#postcode-hint")!.textContent = rule ? `For example, ${rule.example}` : "";
  postcode.required = Boolean(rule);
  postcode.setCustomValidity(postcodeError(postcode.value, c));
  phone.setCustomValidity(phoneError(phone.value, c as CountryCode, false));
}
select.addEventListener("change", syncCountry);
postcode.addEventListener("input", syncCountry);
phone.addEventListener("input", syncCountry);
syncCountry();
Changing the country re-validates dependent fields When the user changes the country selector, the script updates the postcode label and rule, re-validates the postcode and re-parses the phone number against the new default country. User Country select Postcode field Phone field choose Canada label "Postal code", rule A1A 1A1 re-parse with default CA "SW1A 1AA" now fails: "Enter a postal code like K1A 0B1" error shown after blur or submit
Country is an input to every other rule, so a country change must re-run them rather than leaving stale verdicts behind.

State Management and Edge Cases

Address forms are long, often partially autofilled, and frequently revisited after a failed payment. Three state rules keep them predictable.

Treat the country as the root of a dependency graph. Postcode, phone, state or province, and even which fields are shown all derive from it. Recompute dependants on every country change, exactly as the dependent dropdown conditional validation guide describes for other chained fields.

Do not clear user input on country change. A user who picks the wrong country, types a full address and then corrects the country should not lose the address. Re-validate; do not reset.

Autofill can set the country after the other fields. Some browsers fill in document order, others in their own order. The change event on the select fires after the fill, so the re-validation runs with the right country; do not validate postcodes in an input handler that assumes the country was already correct.

How strictly to validate an address field A decision tree for choosing between rejecting, warning and accepting for a given address or phone field value. Is there a reliable rule for this country? no Accept as typed yes Does the value fail it for certain? yes Reject with example no Only unusual, not impossible? yes Warn, allow submit no Accept
Reject only what is certainly wrong for the chosen country; everything uncertain becomes a warning or is left to verification after submit.

Accessibility Compliance for Phone and Address Fields

WCAG 1.3.5 Identify Input Purpose is the headline criterion: every field here has a standard autocomplete token, and using them lets browsers, password managers and assistive tools fill the whole form in one step. That matters more for people with motor or cognitive disabilities than any amount of inline validation. 3.3.2 Labels or Instructions requires the format example to be visible, not only a placeholder; the hint paragraph above satisfies it and changes with the country. 3.3.7 Redundant Entry requires that an address already given — for shipping — is offered again for billing, typically as a “same as shipping” checkbox that copies the values and still validates them.

Group the address fields in a <fieldset> with a <legend> such as “Delivery address”, and a second one for billing. Screen reader users then hear which address they are editing each time focus enters the group, which removes the most common confusion on two-address checkout pages. Errors inside the group should still be tied to individual fields with aria-describedby; a single error on the fieldset (“Check the delivery address”) is a useful summary but never a replacement for field-level messages, because it does not tell the user which of six inputs to fix. The error summary pattern that links to each failing field is covered in building an accessible error summary.

When the postcode label changes with the country (“ZIP code”, “Eircode”), the change must reach screen reader users. Updating the <label> text is enough: the new name is read the next time the field receives focus. Do not announce the change through a live region — the user is focused on the country selector, and an unexpected announcement about another field is confusing.

Common Gotchas and Debugging

A single phone regex for the world. ^\d{10}$ rejects every non-North-American number and every number typed with a +. Replace it with metadata-driven parsing.

// Before
const ok = /^\d{10}$/.test(phone.value);
// After
const ok = parsePhoneNumberFromString(phone.value, country)?.isPossible() ?? false;

Required state or province everywhere. Only some countries use a first-level subdivision in addresses. Make the field conditional on the country, and label it by the local term (state, province, county, prefecture).

Rejecting addresses without house numbers. Many rural addresses, and many outside North America, have a house name or none. Never require a digit in address line 1.

type="number" for postcodes. It destroys leading zeros (US ZIP 02134 becomes 2134) and rejects letters used by most countries. Use a text input with autocomplete="postal-code".

Phone formatting on every keystroke. An as-you-type formatter that inserts spaces and brackets must preserve the caret, exactly like the card number formatter in detecting card brand and formatting input. If that is too fiddly, format on blur only.

Line Lengths, Character Sets and Carrier Limits

Postal carriers and label printers impose limits that the address form should know about, because a value that validates in the browser but is truncated on the shipping label is a failed delivery. Most carriers accept around 35 characters per address line, some as few as 30; many label systems only print Latin-1 characters. The form should not silently enforce these limits by cutting text off at maxlength — a truncated street name is worse than an error — but it should warn when a line is longer than your carrier accepts and suggest moving the overflow to line 2.

const CARRIER_LINE_MAX = 35;

export function addressLineWarning(value: string): string {
  const length = [...value.trim()].length;
  if (length <= CARRIER_LINE_MAX) return "";
  return `This line has ${length} characters. Our courier prints ${CARRIER_LINE_MAX}; move part of it to address line 2.`;
}

This is a warning, not a validity error, so it is rendered as hint text and never passed to setCustomValidity(). Blocking submission on carrier limits pushes users into abbreviating in ways the courier understands even less. The distinction between blocking errors and non-blocking warnings is the same one the password strength meter draws between rules and advice.

Character sets deserve the same care. Names and streets in Vietnamese, Polish, Turkish or Icelandic use characters outside ASCII, and many users will type them correctly. Accept them. If the label printer cannot render them, transliterate on the server when generating the label, and keep the original for display and correspondence. Rejecting Łódź or Straße as “invalid characters” fails the user for a limitation of your printer. The general approach to validating names and free text across scripts is covered in Unicode-aware name field validation.

Testing Address Forms Across Countries

Address validation bugs are almost always country-specific, which means a test suite that only uses one country’s addresses will pass while customers elsewhere fail. Build a fixture table with at least one real-shaped address per supported country — including the awkward ones: a UK address with no house number, an Irish address with an Eircode, a Japanese address in reverse order, a US address with a ZIP+4 code, a Hong Kong address with no postcode — and run every rule against it.

import { describe, it, expect } from "vitest";

const FIXTURES = [
  { country: "GB", line1: "Rose Cottage", city: "Little Snoring", postcode: "NR21 0AA", phone: "01328 123456" },
  { country: "IE", line1: "12 Main Street", city: "Dublin 2", postcode: "D02 X285", phone: "01 234 5678" },
  { country: "US", line1: "1600 Amphitheatre Pkwy", city: "Mountain View", postcode: "94043-1351", phone: "(650) 253-0000" },
  { country: "HK", line1: "Flat 5, 12/F, Tower 2", city: "Kowloon", postcode: "", phone: "2123 4567" },
  { country: "JP", line1: "1-1 Chiyoda", city: "Chiyoda-ku, Tokyo", postcode: "100-8111", phone: "03-3213-1111" },
] as const;

describe.each(FIXTURES)("$country address", (a) => {
  it("postcode passes", () => expect(postcodeError(a.postcode, a.country)).toBe(""));
  it("phone is possible", () => expect(phoneError(a.phone, a.country, false)).toBe(""));
});

Pair the unit fixtures with one end-to-end test per region that autofills the form from a browser profile — Playwright can seed profile data through a persistent context — because autofill ordering bugs never show up in unit tests. The testing layers are laid out in the testing and accessibility section; for the address form specifically, the most valuable single test is “every fixture address submits successfully”, run on every change to the rule tables.

Address rules that reject real customers Six cards naming common over-strict address and phone rules and the customers each one turns away. Ten-digit phone regex rejects every number outside North America and any number typed with + Required state field blocks countries without first-level subdivisions in addresses Five-digit postcode fails Canada, the UK, the Netherlands and Ireland House number required fails named rural addresses in Ireland and the UK ASCII-only names rejects Łódź, Straße and most non-English street names type=number postcode strips leading zeros from US and French codes
Each of these rules looks reasonable for one country and fails another; the fixture table exists to catch them before customers do.

Storing Validated Addresses for Reuse

Validation effort is wasted if the validated address is stored in a shape that loses information. Store the country as an ISO code, the phone as E.164, and the postcode in its normalised display form; keep address lines as the user typed them rather than re-casing or abbreviating them. When the user returns, prefill from the stored values and run the same validators on load — rules and carrier limits change, and an address that was fine last year may need a new postcode format today. Showing a stored address with a quiet “Please check this address” note is far kinder than letting it fail at payment.

Reuse also satisfies WCAG 3.3.7 Redundant Entry. A returning customer should pick a saved address from a list, not retype it, and a billing form should offer “same as delivery address”. Both paths write several fields at once, which is exactly the situation handled in validating autocompleted address fields: write the values, dispatch bubbling input events, and let one coalesced pass run the dependent rules.

Finally, keep the server’s copy of these rules in step with the client’s. The postcode table, the phone metadata version and the carrier limits should live in one module that both bundles import, so a rule relaxed for one country is relaxed everywhere at once — the approach described in shared client–server schemas.

Browser Compatibility Matrix

Address and phone feature support Support for the platform features used by phone and address validation across Chromium, Firefox and Safari. Chromium Firefox Safari type=tel keypad ✓ Yes ✓ Yes ✓ Yes Address autocomplete tokens ✓ Yes ✓ Yes ✓ Yes Intl.DisplayNames region ✓ 81+ ✓ 86+ ✓ 14.1+ Fills country select ✓ Yes partial ✓ Yes
Everything the baseline relies on ships everywhere; address autofill quality varies more by browser profile data than by engine.
Feature Chromium Firefox Safari Notes
autocomplete="tel" Yes Yes Yes Fills with + prefix when stored internationally
autocomplete="street-address" (textarea) Yes Yes Yes Alternative to line1/line2 split
Country <select> autofill Yes Matches option text in some versions Yes Use ISO codes as option values
Intl.DisplayNames 81+ 86+ 14.1+ Fallback: ship a static name table

Address lookup services (type a postcode, pick an address) reduce errors further for countries with good postal data. Treat them as an enhancement: the manual fields must remain usable, and a lookup-selected address still goes through the same validation in validating autocompleted address fields.

Frequently Asked Questions

Should I validate phone numbers with a regular expression?

Not for international numbers. Lengths and ranges differ by country and change over time. Use a metadata-driven parser such as libphonenumber-js, check isPossible() at minimum, and store the E.164 form.

Is postcode required for every country?

No. Several countries and territories have no postal codes, and others do not require them for delivery. Make the field conditional on the selected country and accept it as typed where you have no reliable rule.

Why ask for the country first?

Phone formats, postcode rules, field labels and whether a state or province is needed all depend on the country. Asking first lets every later field validate and format correctly while the user types.

Can client-side validation confirm that an address or phone number is real?

No. It can only reject values that are certainly malformed. Reachability and deliverability need an SMS or call verification, or an address lookup service, after submission.

← Back to Validating Common Input Types

Explore This Section