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.
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();
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.
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.
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
| 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.
Related Guides
- International Phone Validation with libphonenumber — parsing, formatting and storing phone numbers.
- Postal Code Validation by Country — the per-country rule table in full.
- Validating Autocompleted Address Fields — making autofill and lookups trigger validation.
- Dependent Dropdown Conditional Validation — the country-first dependency pattern in general form.
← Back to Validating Common Input Types