A phone field masked as (___) ___-____ rejects every number outside North America, and a regex like ^\d{10,11}$ rejects the same UK number written as +44 7700 900123, 07700 900123 or 07700-900-123 — while happily accepting 0000000000.
Phone numbering is a genuinely complex, per-country, frequently updated dataset, which is why this field should use a library built on it — typically libphonenumber-js, a JavaScript port of Google’s libphonenumber metadata. This page, part of input masking and formatting, parses whatever users type, validates against real numbering plans, stores numbers in E.164, and presents them nicely — without a fixed mask.
Context and prerequisites
Concepts the library handles:
- Default country.
07700 900123is only meaningful with a country. Parse with the user’s likely country (from locale, address or a selector); numbers typed with+and a country code override it. - E.164 — the canonical international format:
+447700900123. Store and compare this; it is unambiguous. - Validity levels. Possible (right length for the country) versus valid (matches the numbering plan’s patterns). Validity data changes as countries add ranges, so keep the library updated.
- Number type. Mobile, fixed line, toll-free, premium — useful when the field must receive SMS.
- Formatting. National (
07700 900123), international (+44 7700 900123) and an as-you-type formatter.
Metadata size is the main trade-off: full metadata is large; libphonenumber-js offers min metadata (validity by length only), max (full patterns, larger) and custom builds for the countries you serve.
The core pattern: parse with a default country, validate, store E.164
import { parsePhoneNumberFromString, AsYouType, type CountryCode } from "libphonenumber-js/max";
export type PhoneResult =
| { ok: true; e164: string; national: string; international: string; country?: CountryCode; type?: string }
| { ok: false; message: string };
export function checkPhone(raw: string, defaultCountry: CountryCode, opts: { requireMobile?: boolean } = {}): PhoneResult {
const text = raw.trim();
if (!text) return { ok: false, message: "Enter a phone number." };
// Extensions: accept "ext. 123", "x123", "#123" — the library recognises common forms.
const phone = parsePhoneNumberFromString(text, defaultCountry);
if (!phone) {
return { ok: false, message: "Enter a phone number, like 07700 900123 or +44 7700 900123." };
}
if (!phone.isPossible()) {
return { ok: false, message: "This phone number is too short or too long." };
}
if (!phone.isValid()) {
return { ok: false, message: "Enter a valid phone number. Check the area code and number." };
}
const type = phone.getType(); // "MOBILE", "FIXED_LINE", "FIXED_LINE_OR_MOBILE", ...
if (opts.requireMobile && type && !["MOBILE", "FIXED_LINE_OR_MOBILE"].includes(type)) {
return { ok: false, message: "Enter a mobile number so we can send you a text." };
}
return {
ok: true,
e164: phone.number, // "+447700900123" — store this
national: phone.formatNational(), // "07700 900123"
international: phone.formatInternational(), // "+44 7700 900123"
country: phone.country,
type,
};
}
// Optional light formatting while typing, preserving user intent.
export function formatAsYouType(text: string, defaultCountry: CountryCode) {
return new AsYouType(defaultCountry).input(text);
}
<label for="phone">Mobile number</label>
<p id="phone-hint">We'll text a code to this number. Include the country code if it's not a UK number.</p>
<input id="phone" name="phone" type="tel" inputmode="tel" autocomplete="tel"
aria-describedby="phone-hint" dir="ltr">
Step-by-step walkthrough
- Use
type="tel"andautocomplete="tel". Mobile users get the phone keypad; autofill offers saved numbers — see autocomplete tokens for autofill-friendly forms. - Choose a default country deliberately. From the user’s address country, account locale, or a country selector; not just
navigator.language, which reflects language, not location. - Parse leniently. Accept spaces, dashes, brackets, dots and
+; let the library strip them. Never block characters in the input. - Validate on blur. Check possible, then valid, then (if needed) type. Show specific messages; the library tells you which check failed.
- Store E.164. It is the only representation that compares correctly across formats and countries.
- Display in the user’s context. National format for numbers in the user’s country, international for others. Format on blur, or as you type with
AsYouTypeif you apply caret mapping, per preserving caret position in masked inputs.
Why the only real validation is a message that arrives
A number can be valid by every numbering-plan rule and still not belong to the user — mistyped by one digit into someone else’s valid number, or disconnected last month. Library validation catches malformed and impossible numbers quickly, which is its job. When the number matters (two-factor authentication, delivery updates), confirm it by sending a one-time code, and design the confirmation step so users can easily correct the number if the code does not arrive. The code-entry field has its own accessibility and autofill considerations, covered in one-time code inputs: keyboard, paste and autofill.
Failure modes and edge cases
1. The trunk prefix in international format
+44 (0) 7700 900123 includes the UK trunk prefix 0 in brackets — common in business signatures and incorrect when dialling internationally. The library handles it; a hand-written parser usually produces an invalid number.
2. Metadata size
libphonenumber-js/max includes full validation patterns and is considerably larger than /min. If you serve a known set of countries, generate custom metadata; if bundle size is critical, use /min on the client for length checks and full validation on the server.
3. Country selectors
A country dropdown next to the field is helpful for international audiences, but users often paste numbers with + that disagree with the selected country. Let the + prefix win and update the selector, rather than rejecting the number.
4. Extensions
Business numbers may include extensions. Store the extension separately (phone.ext) or in the RFC 3966 URI form, and do not strip it silently.
5. Right-to-left pages
Phone numbers are left-to-right strings. In RTL layouts, set dir="ltr" on the input so digits and + do not render in confusing order.
Verification checklist
Frequently Asked Questions
Can I validate phone numbers with a regex?
Only very loosely — for example “a + or digit followed by 7 to 15 digits and common separators”. That catches typing mistakes but not invalid ranges, and it cannot normalise to E.164. Use a regex as a quick pre-check at most.
Should the field be required?
Only if you genuinely need it. Phone numbers are sensitive personal data; ask for them when there is a clear purpose and say what it is in the hint. If contact can be by phone or email, see requiring at least one of several fields.
Does the server need the same library?
Yes, or its equivalent in the server language (libphonenumber exists for Java, C++, Python and others). The server should re-parse and store E.164 itself rather than trusting the client’s normalised value.