International Phone Validation with libphonenumber

How do you validate a phone number field that must accept 07700 900123 from a British user, (415) 555-0132 from an American one and +49 30 901820 from anyone, while still rejecting 12345 and storing every number in one canonical form? This recipe parses input with libphonenumber-js against a default country, distinguishes “wrong length” from “not an allocated range”, stores the E.164 form (+447700900123), formats the display on blur, and reports failures through setCustomValidity() so the field fails the same way as any native constraint in the Constraint Validation API.

When to Use Metadata-Driven Phone Validation

Use this approach whenever your users may be outside one numbering plan — which, for anything on the public web, is always. The library ships per-country metadata (lengths, prefixes, number ranges and formats) derived from Google’s libphonenumber, which is what Android and most telecoms software use.

  • Use isPossible() as the blocking rule on most forms. It checks length for the country and is essentially never wrong.
  • Use isValid() when a wrong number is expensive — two-factor SMS, delivery drivers — and accept that it can lag new number ranges for a release or two.
  • Keep a simple pattern only for strictly local, internal tools where every user shares one plan, and even then prefer the library for consistency.

The phone and address validation topic explains why the country selector comes first: the parser uses it as the default when the user types a national number without a + prefix.

isPossible versus isValid Two columns comparing libphonenumber's length-based isPossible check with the range-based isValid check. isPossible() • checks digit count for the country ✓ tiny metadata, fast ✓ virtually no false rejections ✗ accepts unallocated ranges isValid() • checks allocated number ranges ✓ catches wrong prefixes and many typos ✗ needs the larger max metadata ✗ can reject brand-new ranges
isPossible almost never rejects a real number; isValid catches more typos but depends on metadata being current.

Minimal Working Phone Validator

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

type Strictness = "possible" | "valid";

export interface PhoneResult { error: string; phone?: PhoneNumber }

export function checkPhone(raw: string, defaultCountry: CountryCode, strictness: Strictness = "possible"): PhoneResult {
  const value = raw.normalize("NFKC").trim();
  if (value === "") return { error: "" }; // let `required` report emptiness
  if (/[^\d\s()+.\-\/]/.test(value.replace(/\s*(ext\.?|x)\s*\d+$/i, ""))) {
    return { error: "Phone numbers can only contain digits, spaces, brackets, dashes and +." };
  }
  const phone = parsePhoneNumberFromString(value, defaultCountry);
  if (!phone) return { error: "Enter a full phone number, including the area code." };
  if (!phone.isPossible()) {
    return { error: "This phone number has too many or too few digits." };
  }
  if (strictness === "valid" && !phone.isValid()) {
    return { error: "Check this phone number — the area or mobile code doesn't look right.", phone };
  }
  return { error: "", phone };
}

// Wiring
const form = document.querySelector<HTMLFormElement>("#contact")!;
const country = form.querySelector<HTMLSelectElement>("#country")!;
const field = form.querySelector<HTMLInputElement>("#phone")!;
const e164 = form.querySelector<HTMLInputElement>("#phone-e164")!; // type="hidden"

function validatePhone(format: boolean): void {
  const { error, phone } = checkPhone(field.value, country.value as CountryCode, "possible");
  field.setCustomValidity(error);
  e164.value = error ? "" : phone?.number ?? "";
  // Show the international format when it differs from the user's country, national otherwise.
  if (format && phone && !error) {
    field.value = phone.country === country.value ? phone.formatNational() : phone.formatInternational();
  }
}

field.addEventListener("input", () => validatePhone(false));
field.addEventListener("blur", () => validatePhone(true));
country.addEventListener("change", () => validatePhone(false));

form.addEventListener("submit", (event) => {
  validatePhone(false);
  if (!form.checkValidity()) {
    event.preventDefault();
    form.reportValidity();
  }
});

Storing the E.164 form in a hidden field means the server receives one canonical value regardless of how the user typed or how you formatted the display. The server still re-parses it — hidden inputs are as editable as any other — but it never has to guess the country.

From typed phone number to stored E.164 A typed national phone number is combined with the selected default country, parsed by libphonenumber, checked for possible length, and stored in E.164 form while the display is formatted nationally. Typed value 07700 900123 Default country GB from the selector Parse national number 7700900123 isPossible 10 national digits, OK for GB Store E.164 +447700900123
The default country only matters for numbers typed without a plus sign; the stored value never depends on how the user typed it.

Phone Validator Option Reference

Option Type Default Purpose
Metadata bundle import path libphonenumber-js/max min (~80 kB) for length checks, max (~145 kB) for isValid()
defaultCountry CountryCode from selector Used when the number has no + prefix
strictness "possible" | "valid" "possible" Length-only or full range check
Extension handling regex ext / x suffix Allowed on business numbers
Display format formatNational / formatInternational on blur Readable confirmation for the user
Submitted value phone.number E.164 Canonical storage format
autocomplete token tel Browser fills the full international number

Bundle size matters on mobile. Load the parser lazily on first focus of the phone field — the same technique used for the password strength meter — so a 145 kB metadata file never blocks first paint.

let lib: Promise<typeof import("libphonenumber-js/max")> | undefined;
field.addEventListener("focus", () => (lib ??= import("libphonenumber-js/max")), { once: true });

Verification Steps

import { test, expect } from "@playwright/test";

test("national number is stored as E.164", async ({ page }) => {
  await page.goto("/contact");
  await page.getByLabel("Country or region").selectOption("GB");
  const phone = page.getByLabel("Phone number");
  await phone.fill("07700 900123");
  await phone.blur();
  await expect(page.locator("#phone-e164")).toHaveValue("+447700900123");
  expect(await phone.evaluate((el: HTMLInputElement) => el.validity.valid)).toBe(true);
});

Edge Cases and Failure Modes

Numbers typed with the trunk prefix and a country code. Users write +44 (0)7700 900123. The (0) is a national trunk prefix that must be dropped after +44; libphonenumber handles this, but a pre-filter that strips brackets naively can turn it into +4407700…, which fails. Let the library see the original characters.

Short codes and emergency numbers. 999 or 112 are not subscriber numbers and should fail on a contact form. They will: isPossible() rejects them for every country.

As-you-type formatting fights editing. AsYouType formats progressively, but assigning its output to the input on every keystroke moves the caret. Either restore the caret by counting digits (as with card numbers) or format only on blur, which is what the recipe does.

Metadata drift. A new mobile range launched last month may fail isValid() until you upgrade the package. Keep the dependency current, and prefer isPossible() as the blocking rule unless a wrong number is costly.

Asking for Phone Numbers Only When You Need Them

The most effective phone validation is not asking. Every extra required field reduces completion, and phone numbers are among the fields people are most reluctant to give. Make the field optional unless there is a concrete use — delivery contact, two-factor authentication, appointment reminders — and say what the use is in the hint text next to the field, as in “We only call about delivery problems.” When it is optional, an empty value must pass: return an empty error string and let required stay off, rather than running the parser on an empty string and producing “Enter a full phone number”. When it is required for two-factor authentication, validation is only half the job; the number is proven by sending a one-time code, and the form that accepts the code should use autocomplete="one-time-code" and inputmode="numeric" so the platform can fill it from the SMS automatically. The mobile keyboard side of this is covered in choosing inputmode and enterkeyhint.

Re-Validating Phone Numbers on the Server

The hidden E.164 field makes the server’s job simpler but not optional. Parse the submitted value again with the same library and the same strictness, reject anything that does not start with + followed by digits, and never trust the hidden field over the visible one without checking they agree — a user with scripting disabled submits only the visible value, and a tampered request can send any pair. The server is also the right place for checks the browser cannot do: whether the number is a mobile line that can receive SMS (libphonenumber’s getType() gives a hint; a carrier lookup gives the answer), and whether the same number is already registered to another account.

Splitting Country Code and Number Into Two Fields

Some designs show a country-code dropdown with flags next to a national-number input. It looks tidy, but it creates problems validation cannot fully fix: autofill fills tel with a full international number into the second field, users paste +44… into the national field, and screen reader users hear a list of 200 flags with dialling codes. If you use the split design, parse the national field with the selected code and also accept a leading + in it, overriding the dropdown when present. A single field with the country inferred from the address selector, as in this recipe, avoids all three issues and is simpler to test.

Phone field with national formatting A contact form showing a UK mobile number formatted on blur, with the hidden E.164 value and the hint text annotated. How can we reach you? Country or region United Kingdom Phone number (optional) 07700 900123 1 ✓ Saved as +447700900123 Continue 1 Hidden input phone-e164 holds the canonical value; the server re-parses it anyway 2 autocomplete="tel" lets the browser fill the full international number 3 The hint explains why the number is wanted, which raises completion
The display keeps the user's familiar national format while the hidden field carries the canonical E.164 value to the server.

Frequently Asked Questions

Should phone numbers be validated with a regex?

Only for a single, known numbering plan. For international input use a metadata-driven parser such as libphonenumber-js, which knows each country's lengths and prefixes and is kept up to date.

What format should I store phone numbers in?

E.164: a plus sign, the country code and the national number with no spaces, such as +447700900123. It is unambiguous, sorts well and is what SMS and telephony APIs expect.

Is isValid always better than isPossible?

No. isValid() catches more typos but can reject numbers from newly allocated ranges until the metadata updates. Use isPossible() as the blocking rule unless a wrong number is expensive.

How do I keep libphonenumber from bloating my bundle?

Import it dynamically when the phone field first receives focus, and choose the min metadata if you only need length checks.

← Back to Phone and Address Validation