What you are looking at
One number, one canonical form, and a short pipeline between them.
Every step in the demo is a real thing a normaliser has to do, in order. First it cleans the separators, so only digits and a possible leading plus remain. Then it reads a leading 00 as the international plus. Then it resolves the country, from the plus if there is one, or from a default region if there is not. Then it drops the national trunk zero. What comes out is E.164.
That last shape is the point. Two people can write the same number a dozen ways, but it has exactly one E.164 form, so that is the value you store and the value you match on.
The rules, one by one
Four rules cover almost every number a European form sees.
Each toggle in the demo is one of these. On their own they read as obvious. The trouble is that they interact, and the edge cases are where hand-written code quietly gets it wrong.
Strip separators
Spaces and brackets are decoration
Humans write 070-123 45 67 or (0)70 123 4567 for readability. None of it is part of the number. Remove everything that is not a digit, keeping only a leading plus, before you try to make sense of what is left.
Read 00 as +
The old way of writing a plus
Before the plus sign, you dialled 00 to leave the country, and plenty of people still type 0046. A leading 00 means the same thing as +, so a normaliser swaps it. Skip this and 0046 gets treated as a very long national number.
Drop the trunk 0
The zero that only works from inside
A national number like 070 carries a leading trunk 0 for dialling inside the country. Once a country code is attached, that zero is wrong. This is also the stray 0 in +46 (0)70: same rule, dropped after the country is known.
Assume a region
When there is no country code at all
A bare 070 123 45 67 has no plus and no 00, so nothing states the country. You supply a default region for that case. It is a guess, so it is a setting, not a constant, and it belongs next to the form or import that produced the number.
// The version everyone writes first. Do not ship it.
function normalise(input) {
let n = input.replace(/[^\d+]/g, ''); // strip spaces, dashes, brackets
if (n.startsWith('00')) n = '+' + n.slice(2); // 00 -> +
if (!n.startsWith('+')) n = '+46' + n.replace(/^0/, ''); // assume SE, drop trunk 0
return n;
}
normalise('0046 70-123 45 67'); // '+46701234567' correct, by luck
normalise('+46 (0)70 123 45 67'); // '+460701234567' WRONG: the stray 0 survivesIt handles the cases you thought of and fails the one you did not. The trunk 0 only gets dropped when the code prepends +46 itself, so a number that already starts with +46 keeps its stray 0. Every country you add is another branch, and every branch is another of these.
Why the hand-rolled version loses
The rules are not hard. Holding all of them, for every country, is.
The four rules above are the easy 90 percent. The rest is a long tail of country-specific knowledge that a regex cannot carry, and that changes without asking you.
What a library knows that your regex does not
The long tail
Country calling codes vary in length, from +1 to +354, so matching the country is not a fixed slice.
Not every country has a trunk 0. Drop it blindly and you corrupt Norwegian, Danish, and Icelandic numbers.
Valid lengths differ by country and by number type, so a real check is more than counting digits.
Numbering plans change. Libraries ship updates; a regex you wrote last year does not.
Formatting a friendly local version is its own per-country ruleset, the same problem in reverse.
import { parsePhoneNumber } from 'libphonenumber-js';
// defaultCountry is used only when the input has no + or 00.
function toE164(input, defaultCountry = 'SE') {
const phone = parsePhoneNumber(input ?? '', defaultCountry);
if (!phone || !phone.isValid()) return null;
return phone.number; // canonical E.164, e.g. '+46701234567'
}
toE164('+46 (0)70 123 45 67'); // '+46701234567' stray 0 handled
toE164('0046 70 123 45 67'); // '+46701234567' 00 handled
toE164('070-123 45 67', 'SE'); // '+46701234567' default region usedThe whole pipeline, and the entire long tail, collapses to one parse and a validity check. libphonenumber-js is the trimmed JavaScript port of Google’s libphonenumber; parsePhoneNumber returns a rich object, and .number is the canonical E.164 string you store.
Even "valid" is a claim to check
The library is right that the number is real, not that it is the same one.
libphonenumber knows every numbering plan, so reach for it. But isValid() answers one question, is this a real, dialable number, and that is not the same question as, is this the number the person typed. Two more checks close the gap.
Two checks the library leaves to you
Validity is not identity; possible is not plausible.
Validity is not identity. A trunk-prefix stripper can turn one valid number into a different valid number, and isValid() waves it through. Compare the significant digits of the result against the input and refuse on a mismatch, so a silent corruption becomes an explicit "not sure".
Possible is not plausible. isPossible() accepts short codes and fragments that are technically dialable, so a truncated 06760 passes as a real number. Add a length floor per number type to reject the fragment while keeping genuinely short numbers.
The same shape guards more than phones: an IBAN formatter that regroups spaces but must not alter a digit, a date parser that must not shift the day across a timezone, a URL canonicaliser that must not drop a query parameter that changes which resource is addressed.
Decide what the significant part is before you diff it. For a phone it is the national digits; for a URL it is the host, path, and meaningful query, but not the trailing slash.
Setting it up in an internal tool
Normalise once, at the edge, and store three columns.
The value of E.164 shows up when it is the single canonical column everything else agrees on. That means normalising at the moment a number enters the system, a form submit or a CSV import, not in every query afterwards.
Store
One canonical column, and never lose the original
Keep three fields: the E.164 string you match and deduplicate on, a friendly local format for display, and the exact text the user typed. If parsing ever disagrees with a human, the raw value is your evidence.
Deduplicate
Match on E.164, not on what was typed
This is the link to a data audit. Two contacts that looked distinct because one wrote +46 and the other 070 collapse to the same record once both are E.164. Normalising before matching is what makes duplicate detection honest.
Configure
Default region is a setting, not a constant
The default country for numbers with no code depends on where the form or import comes from. Make it a per-source setting. A Swedish signup form and a UK spreadsheet should not share one hard-coded guess.
import { parsePhoneNumber } from 'libphonenumber-js';
// On import: one canonical column, one for display, and the original kept.
function normaliseRow(row, defaultCountry) {
const phone = parsePhoneNumber(row.phone ?? '', defaultCountry);
return {
...row,
phone_e164: phone?.isValid() ? phone.number : null, // store + dedupe on this
phone_display: phone ? phone.formatNational() : row.phone, // show this
phone_raw: row.phone, // never lose the original
};
}
// On a form field: tidy the value as the user leaves it.
onBlur={(e) => {
const phone = parsePhoneNumber(e.target.value, country);
if (phone?.isValid()) setValue(phone.formatInternational());
}}The same parse serves both entry points. The importer writes the three columns; the form tidies the field on blur so people see a clean +46 70 123 45 67 as they go, while the value you store stays the bare E.164.
The rule of thumb
Store one shape. Let a library find it, never a regex you maintain by hand.
The pipeline in the demo is worth understanding, because it tells you what the library is doing and why a number came out the way it did. But the code you ship parses to E.164 with libphonenumber, stores that one canonical value, keeps the original, and formats local only for the screen.
