DEV Community

Citation Builder
Citation Builder

Posted on

Normalizing Business Phone Numbers to E.164 for Six Countries, Without a Library (and the Italian Zero)

A business listing is matched across the web largely by its phone number. When one directory says (416) 555-0199 and another says +1 416 555 0199, a human sees the same number and a naive string comparison sees two businesses. If you store, compare or publish phone numbers for local businesses, normalize them to E.164 first: a plus sign, the country code and the national number, no spaces.

The usual answer is "use libphonenumber", and for anything serious you should. But it is worth knowing what the library does for you, because the rules are small and one of them catches people out every time. This post is a dependency-free normalizer for six markets, the output it produced, and the three rules behind it.

The code

Node 20 or newer, no dependencies:

// Normalize a business phone number to E.164 for six markets, without a library.
// Node 20+, no dependencies:  node e164.mjs
const RULES = {
  CA: { dial: '1', keepTrunkZero: false, nsn: [10] },      // NANP: area code + 7 digits, trunk prefix "1"
  IE: { dial: '353', keepTrunkZero: false, nsn: [7, 8, 9] },
  NL: { dial: '31', keepTrunkZero: false, nsn: [9] },
  IT: { dial: '39', keepTrunkZero: true, nsn: [6, 7, 8, 9, 10, 11] }, // landlines keep their leading 0
  FR: { dial: '33', keepTrunkZero: false, nsn: [9] },
  JP: { dial: '81', keepTrunkZero: false, nsn: [9, 10] },
};

function toE164(raw, country) {
  const rule = RULES[country];
  if (!rule) throw new Error(`no rule for ${country}`);
  let digits = String(raw).replace(/[^\d+]/g, '');
  if (digits.startsWith('+')) digits = digits.slice(1);                 // already international
  else if (digits.startsWith('00')) digits = digits.slice(2);           // European international prefix
  else if (country === 'CA' && digits.length === 11 && digits.startsWith('1')) { /* 1 + 10 digits */ }
  else if (country === 'CA') digits = rule.dial + digits;
  else if (digits.startsWith('0') && !rule.keepTrunkZero) digits = rule.dial + digits.slice(1);
  else digits = rule.dial + digits;
  if (!digits.startsWith(rule.dial)) return { ok: false, reason: 'another country code' };
  const nsn = digits.slice(rule.dial.length);
  if (!rule.nsn.includes(nsn.length)) return { ok: false, reason: `unexpected length ${nsn.length}` };
  return { ok: true, e164: `+${digits}` };
}

// Made-up numbers in each market's usual local format (555-01xx is reserved for fiction in NANP).
const samples = [
  ['CA', '(416) 555-0199'], ['CA', '+1 416 555 0199'],
  ['IE', '01 555 0123'], ['IE', '00353 1 555 0123'],
  ['NL', '020 555 0123'], ['IT', '06 5550 1234'], ['IT', '+39 06 5550 1234'],
  ['FR', '01 55 50 12 34'], ['JP', '03-5550-1234'], ['IE', '+44 20 7946 0000'],
];
console.table(samples.map(([country, raw]) => ({ country, raw, ...toE164(raw, country) })));
Enter fullscreen mode Exit fullscreen mode

What it printed

┌─────────┬─────────┬────────────────────┬───────┬─────────────────┬────────────────────────┐
│ (index) │ country │ raw                │ ok    │ e164            │ reason                 │
├─────────┼─────────┼────────────────────┼───────┼─────────────────┼────────────────────────┤
│ 0       │ 'CA'    │ '(416) 555-0199'   │ true  │ '+14165550199'  │                        │
│ 1       │ 'CA'    │ '+1 416 555 0199'  │ true  │ '+14165550199'  │                        │
│ 2       │ 'IE'    │ '01 555 0123'      │ true  │ '+35315550123'  │                        │
│ 3       │ 'IE'    │ '00353 1 555 0123' │ true  │ '+35315550123'  │                        │
│ 4       │ 'NL'    │ '020 555 0123'     │ true  │ '+31205550123'  │                        │
│ 5       │ 'IT'    │ '06 5550 1234'     │ true  │ '+390655501234' │                        │
│ 6       │ 'IT'    │ '+39 06 5550 1234' │ true  │ '+390655501234' │                        │
│ 7       │ 'FR'    │ '01 55 50 12 34'   │ true  │ '+33155501234'  │                        │
│ 8       │ 'JP'    │ '03-5550-1234'     │ true  │ '+81355501234'  │                        │
│ 9       │ 'IE'    │ '+44 20 7946 0000' │ false │                 │ 'another country code' │
└─────────┴─────────┴────────────────────┴───────┴─────────────────┴────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Every row converges on one canonical form per number, and the last row is rejected instead of being silently rewritten: a UK number entered for an Irish business is a data error, not a formatting choice.

Rule 1: drop the trunk zero, except in Italy

Most countries write national numbers with a trunk prefix, usually 0, that disappears in the international form. A Dublin number written 01 555 0123 becomes +353 1 555 0123. The same holds for the Netherlands (020 becomes +31 20), France (01 becomes +33 1) and Japan (03 becomes +81 3).

Italy is the exception. Italian geographic numbers keep their leading 0 in the international form: a Rome number 06 5550 1234 is +39 06 5550 1234, not +39 6 5550 1234. Strip it and the number is wrong. That is the whole reason for the keepTrunkZero flag, and it is not hypothetical: the normalizer we use for directory submissions carries Italy as its only "keep the zero" country for exactly this reason.

Rule 2: North America has no zero, but it has a 1

Canada and the US share the North American Numbering Plan: a 3-digit area code and a 7-digit number, with "1" as the country code and as the domestic long-distance prefix. So a 10-digit Canadian number gets +1 in front, an 11-digit number that already starts with 1 does not get a second one, and nothing is stripped.

Rule 3: validate the length after you normalize, not before

Local formats vary too much to validate on input: spaces, dashes, parentheses, dots, a leading 00 for the international prefix. Strip everything to digits, apply the country rule, then check that the national significant number has a length the country actually uses. Ireland mixes 7, 8 and 9 digit numbers, Italy anything from 6 to 11, France and the Netherlands exactly 9. A wrong length is a typo, and a number with another country code is a data error. Both should be reported, never guessed.

Where this matters

Local search engines, map apps and AI assistants reconcile a business across many listings, and the phone number is one of the strongest keys they have, so one listing with a different format or a missing digit is enough to turn one business into two records. Each of the country pages linked above also covers the local address conventions that cause the same kind of mismatch, from Dutch postcodes to French two-digit phone blocks.

The rules here cover the common cases in these six markets. For production code with every country, mobile versus fixed-line detection and number ranges, use libphonenumber: it is the same idea with the full rulebook.

Top comments (0)