# Phone number validation in JavaScript and Node.js

> Validate phone numbers in JavaScript with libphonenumber-js: min vs max metadata, E.164, form input, and a live line-type check with runnable Node code.

Canonical: https://mobilevalidate.com/blog/phone-number-validation-javascript · Last updated: 2026-09-25

![Cover: Phone number validation in JavaScript and Node.js](https://mobilevalidate.com/og/blog/phone-number-validation-javascript.png)


By MobileValidate team (https://mobilevalidate.com/about) · Published: 2026-09-25 · Category: Developers · Tags: Phone validation, Javascript, Nodejs, Libphonenumber, E.164, API

To validate a phone number in JavaScript, parse it with `libphonenumber-js/max`, call `isValid()`, and store `number` (the E.164 string). That checks the format and the country's numbering plan. It can't tell you whether the line is a mobile or whether it's in service. For that, send the valid E.164 number to a lookup API from your server. This guide has runnable Node code for both steps.

## Why do so many JavaScript answers get phone validation wrong?

Most answers start with a regex. The Stack Overflow question ["Validate phone number with JavaScript"](https://stackoverflow.com/questions/4338267) was asked in 2010 and had over 806,000 views on 2026-09-25, and most of its answers are patterns. A pattern can check that a string looks like a number. It can't know that `+1 123 456 7890` is impossible (no US area code starts with 1) or that a German number has one digit too many.

Phone numbers follow national numbering plans that change all the time. Google's [libphonenumber](https://github.com/google/libphonenumber) published 25 metadata releases in 2025, and every one of them updated the phone metadata of at least one country. A regex you wrote last year can't keep up with that. A library that ships the metadata can, provided you update it.

We compare the patterns and their failures in [E.164 regex: why a pattern is not enough](/blog/e164-regex-is-not-enough). The short version: use a regex, if at all, as a cheap pre-filter, and let a numbering-plan library decide.

## Which library should you use: libphonenumber-js or google-libphonenumber?

For most projects, [libphonenumber-js](https://github.com/catamphetamine/libphonenumber-js). It's a JavaScript rewrite of Google's library, released under the MIT licence, with metadata you can pick by size. Version 1.13.14 was current on 2026-09-25. Google's own JavaScript build is tied to the Closure toolchain, and the library's README estimates it at about 550 kB when bundled.

libphonenumber-js ships three metadata sets. The file sizes below come from version 1.13.14:

| Import | Metadata file | What `isValid()` checks | `getType()` |
|---|---|---|---|
| `libphonenumber-js` (default, "min") | 84 kB | Mostly length and leading digits | Usually `undefined` |
| `libphonenumber-js/mobile` | 99 kB | Exact ranges, mobile numbers only | Mobile only |
| `libphonenumber-js/max` | 157 kB | Exact ranges for every number type | `MOBILE`, `FIXED_LINE`, `TOLL_FREE`… |

The difference is real. On 2026-09-25, `+49 1112 3456789` returned `true` from `isValidPhoneNumber()` with the min metadata and `false` with max. On the server, always import from `libphonenumber-js/max`. In the browser, min is fine for live formatting, as long as the server has the final say.

```bash
npm install libphonenumber-js
```

## How do you parse and validate a number in Node?

Here's a small function that returns either an E.164 number or a reason. We use the same logic in our own API.

```js
// phone.mjs
import { parsePhoneNumberFromString, validatePhoneNumberLength } from "libphonenumber-js/max";

// Our reserved test range (+44 7700 9xxxxx). libphonenumber marks it invalid on purpose.
const TEST_RANGE = /^\+4477009\d{5}$/;

export function normalizePhone(input, defaultCountry, { allowTestRange = false } = {}) {
  const raw = String(input ?? "").trim();
  if (!raw || raw.length > 32) return { ok: false, reason: "unparseable" };
  const p = parsePhoneNumberFromString(raw, defaultCountry);
  if (!p) return { ok: false, reason: "unparseable" };
  if (allowTestRange && TEST_RANGE.test(p.number)) return { ok: true, e164: p.number, country: "GB", type: "TEST" };
  if (!p.isValid()) {
    const len = validatePhoneNumberLength(raw, defaultCountry); // "TOO_SHORT", "TOO_LONG", … or undefined
    return { ok: false, reason: len ? len.toLowerCase() : "invalid" };
  }
  return { ok: true, e164: p.number, country: p.country ?? null, type: p.getType() ?? null };
}
```

Real output from Node 24 with libphonenumber-js 1.13.14:

| Call | Result |
|---|---|
| `normalizePhone("(202) 555-0143", "US")` | `{ ok: true, e164: "+12025550143", country: "US", type: "FIXED_LINE_OR_MOBILE" }` |
| `normalizePhone("07911 123456", "GB")` | `{ ok: true, e164: "+447911123456", country: "GG", type: "MOBILE" }` |
| `normalizePhone("+49 1512 3456789")` | `{ ok: true, e164: "+4915123456789", country: "DE", type: "MOBILE" }` |
| `normalizePhone("+1 800 555 0199")` | `{ ok: true, e164: "+18005550199", country: "US", type: "TOLL_FREE" }` |
| `normalizePhone("+44 1234")` | `{ ok: false, reason: "too_short" }` |
| `normalizePhone("+1 123 456 7890")` | `{ ok: false, reason: "invalid" }` |
| `normalizePhone("202-555-0143")` | `{ ok: false, reason: "unparseable" }` |

Three rows deserve a comment. The UK mobile comes back as `GG` (Guernsey), because `+44 7911` ranges are shared, so don't map country codes to countries yourself. US numbers are `FIXED_LINE_OR_MOBILE`, because the North American plan doesn't separate the two. And a national number with no default country can't be parsed at all. Ask for the country on the form.

## What's the difference between isPossible() and isValid()?

`isPossible()` checks only the length for the country. `isValid()` also checks that the digits fall inside a range the country has allocated. `+1 123 456 7890` has the right length for the US, so it's possible, but it isn't valid, because US area codes never start with 1.

Use `isPossible()` while the user is still typing, if you want to show "keep going" feedback. Use `isValid()` before you save the number or pay for anything downstream. The same distinction exists in every port of libphonenumber: `is_possible_number` in Python, `isPossibleNumber` in PHP and Java.

## How do you handle phone input in a form?

Format as the user types, validate on the server. `AsYouType` gives live formatting and a parsed number when the input is complete:

```js
import { AsYouType } from "libphonenumber-js/min";

const typer = new AsYouType("US");
typer.input("2025550143");      // "(202) 555-0143"
typer.getNumber()?.number;      // "+12025550143"
```

In React, Vue or Svelte, call this in the input's change handler and keep two values in state: what the user sees and the E.164 value you submit. Put a country picker next to the field and pass its value as the default country. Without it, a UK user typing `07911 123456` gives you digits you can't place.

Then run `normalizePhone()` again on the server. Client-side checks are for the user's convenience. Anyone can bypass them.

## How do you check whether the number is a mobile and in service?

Validation stops at the numbering plan. It can't see that a number was ported to a VoIP provider, or that a US number is a landline. That's what a [carrier lookup](/services/carrier-lookup) is for. It returns the current `line_type` and carrier.

Here's a plain `fetch` client for the MobileValidate API (Node 18 or later). It sends numbers in the POST body, waits for slow answers, and retries only errors the API marks as retryable:

```js
// lookup.mjs
const API = process.env.MOBILEVALIDATE_BASE_URL ?? "https://api.mobilevalidate.com";
const headers = {
  Authorization: `Bearer ${process.env.MOBILEVALIDATE_API_KEY}`,
  "Content-Type": "application/json",
};
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function call(path, init = {}, attempt = 0) {
  const res = await fetch(`${API}${path}`, { ...init, headers, signal: AbortSignal.timeout(45_000) });
  const body = await res.json().catch(() => ({})); // e.g. an HTML error page from a proxy
  if (res.ok) return body;
  const err = body.error ?? { code: "http_" + res.status, retryable: res.status >= 500 };
  if (err.retryable && attempt < 3) {
    const retryAfter = Number(res.headers.get("retry-after")) || 2 ** attempt;
    await sleep(retryAfter * 1000);
    return call(path, init, attempt + 1);
  }
  throw Object.assign(new Error(err.message ?? err.code), { code: err.code, status: res.status });
}

// Checks up to 100 E.164 numbers. Numbers go in the POST body, never in the URL.
export async function lookup(numbers, checks = ["carrier"]) {
  let lookup = await call("/v1/lookup", {
    method: "POST",
    body: JSON.stringify({ numbers, checks, wait: 10 }),
  });
  while (lookup.status === "pending") {
    await sleep(lookup.next?.poll_after_ms ?? 2000);
    lookup = await call(`/v1/lookups/${lookup.id}?wait=10`);
  }
  return lookup.results.map((r) => {
    const c = r.checks?.["network.carrier"];
    return {
      e164: r.e164,
      number_status: r.number_status,              // valid | invalid_number | duplicate | suppressed
      status: c?.status ?? null,                   // completed | unknown | pending | unsupported_country
      line_type: c?.attributes?.line_type ?? null, // null = unknown, never "not mobile"
      carrier: c?.attributes?.carrier ?? null,
      reason: c?.reason ?? null,
      checked_at: c?.checked_at ?? null,
    };
  });
}
```

## What does the whole flow look like with a test key?

Test keys (`mv_test_…`) are free and return fixed answers for the [test numbers](/docs/test-mode). This script validates locally, skips what's invalid and checks the rest:

```js
// demo.mjs
import { normalizePhone } from "./phone.mjs";
import { lookup } from "./lookup.mjs";

const testKey = process.env.MOBILEVALIDATE_API_KEY?.startsWith("mv_test_");
const input = ["+44 7700 900001", "07700 900002", "+447700900003", "+447700900004", "+44 1234"];
const ok = [], rejected = [];
for (const n of input) {
  const r = normalizePhone(n, "GB", { allowTestRange: testKey });
  (r.ok ? ok : rejected).push(r.ok ? r.e164 : { input: n, reason: r.reason });
}
console.log("rejected locally:", JSON.stringify(rejected));
console.table(await lookup(ok));
```

Real output (`MOBILEVALIDATE_API_KEY=mv_test_… node demo.mjs`, trimmed):

```text
rejected locally: [{"input":"+44 1234","reason":"too_short"}]
e164            number_status  status     line_type  carrier       reason
+447700900001   valid          completed  mobile     Test Carrier  null
+447700900002   valid          unknown    null       null          NO_DATA
+447700900003   valid          unknown    null       null          UPSTREAM_TIMEOUT
+447700900004   valid          completed  mobile     Test Carrier  null
```

`…004` was pending for about five seconds, so the loop polled once. The `allowTestRange` flag exists because libphonenumber treats `+44 7700 900xxx` as invalid: the UK regulator Ofcom reserves that range for drama and it's never assigned. Our API uses it as the test range, so let those numbers through only when you're using a test key.

With a test key, `+447700900429` makes the API answer `429 rate_limited` with `Retry-After: 1`. In our run the client retried three times, about one second apart, and then threw `rate_limited`, which is the behaviour you want from a batch script.

## How should your code act on each answer?

| Answer | Sign-up form | Stored record |
|---|---|---|
| `ok: false` locally | Ask the user to fix the number; don't call the API | Don't store it as a phone number |
| `line_type: "mobile"` | Continue; SMS is a sensible channel | Store `line_type` and `checked_at` |
| `line_type` is `fixed_line`, `toll_free` or `voip` | Offer voice or e-mail, or add a step, depending on your risk rules | Store it; don't text it |
| `status: "unknown"` | Continue with your default; never block on it | Keep the previous value; retry later |
| `number_status: "duplicate"` | n/a | Deduplicate on the E.164 value |

Unknown answers aren't billed, and they're missing data, not a "no". Don't turn `null` into `false`. The same applies to channel checks such as [WhatsApp registration](/blog/check-if-a-number-is-on-whatsapp-api-guide).

## Can you use the SDK instead of fetch?

Yes. The [`mobilevalidate`](https://www.npmjs.com/package/mobilevalidate) TypeScript SDK (`npm install mobilevalidate`) adds automatic waiting, typed errors and safe retries with idempotency keys. The same lookup looks like this:

```ts
import { MobileValidate } from "mobilevalidate";

const mv = new MobileValidate(); // reads MOBILEVALIDATE_API_KEY
const { data, error } = await mv.lookup(["+447700900001", "+447700900003"], { checks: ["carrier"] });
if (error) console.error(error.code, error.message);
else for (const r of data.results) {
  const c = r.checks?.["network.carrier"];
  console.log(r.e164, c?.status, c?.attributes?.line_type ?? null, c?.reason);
}
// +447700900001 completed mobile null
// +447700900003 unknown null UPSTREAM_TIMEOUT
```

The [SDK docs](/docs/sdk) cover jobs, e-mail checks and webhooks. Until the package is published, the `fetch` client above does the same job.

## What should you test?

Keep two kinds of tests. Unit tests for `normalizePhone()` need no network: one valid number per country you serve, one impossible number (`+1 123 456 7890`), one too short, one without a country. Integration tests run against the API with a test key and the test numbers: `…001` gives data, `…002` and `…003` give `unknown`, `…004` exercises polling, and `…429` exercises your retry path. Nothing is billed and no real network is queried.

Don't use real customer numbers in fixtures. Besides being personal data, they change owners.

## What are the key takeaways?

- Import from `libphonenumber-js/max` on the server. The default min metadata checks little more than lengths.
- Use `isValid()` before storing or paying for anything; `isPossible()` only for live typing feedback.
- Store `p.number` (E.164) and keep the country from the parser, not from the country code.
- Validation can't tell you the line type or whether the number is in service. A carrier lookup can, from your server, with numbers in the POST body.
- Treat `unknown` as missing data, retry only when the API says `retryable`, and build every branch with free test numbers.
- Update libphonenumber-js regularly. Numbering plans change every few weeks.

## Sources

1. [libphonenumber-js](https://github.com/catamphetamine/libphonenumber-js) — GitHub (catamphetamine), 2026
2. [libphonenumber](https://github.com/google/libphonenumber) — Google, 2026
3. [Validate phone number with JavaScript (question 4338267)](https://stackoverflow.com/questions/4338267) — Stack Overflow, 2026
4. [ITU-T Recommendation E.164](https://www.itu.int/rec/T-REC-E.164/en) — International Telecommunication Union, 2026

## Frequently asked questions

### Which library should I use to validate phone numbers in JavaScript?

libphonenumber-js is the most widely used option. It is a JavaScript rewrite of Google's libphonenumber with smaller metadata files. Import it from libphonenumber-js/max on the server if you need full validation and number types.

### Why does isValid() accept numbers that don't exist?

With the default min metadata, isValid() checks mostly lengths and leading digits, not the exact number ranges. Import from libphonenumber-js/max to check ranges. Even then, a valid number can be unassigned or switched off. Only a network lookup tells you that.

### Can I validate phone numbers with a regular expression instead?

Only as a rough pre-filter. A pattern such as ^\+[1-9]\d{1,14}$ accepts numbers with impossible area codes and unassigned country codes. A numbering-plan library knows each country's ranges and lengths.

### Should I call a phone lookup API from the browser?

No. Keep API keys on your server. Validate the format in the browser for quick feedback, then call the lookup API from your backend.
