validation.phone-e164
Normalise a phone number to E.164 (+447700900123) using a default country's trunk and dialling prefixes.
2.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 31 tests, run in TypeScript, Python and Rust.
What it does
Turns a phone number as someone typed it into E.164, the "+" form every SMS gateway and telephony API wants, reading national numbers as dialled from a default country.
## Scope: this is not libphonenumber
For example
validatePhoneE164(07700 900123, GB)→ valid true, normalised +447700900123, calling code 44, national number 7700900123, reason — a UK mobile in national form loses its trunk 0validatePhoneE164(+44 7700 900123, US)→ valid true, normalised +447700900123, calling code 44, national number 7700900123, reason — an international number ignores the default countryvalidatePhoneE164(+44 7700 900123, )→ valid true, normalised +447700900123, calling code 44, national number 7700900123, reason — international form needs no default country
The function
The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.
export function validatePhoneE164(value: string, defaultCountry: string): PhoneE164
| value | string | as typed: "07700 900123", "+44 (0)20 7946 0018", "(212) 555-0123" |
| defaultCountry | string | ISO 3166 alpha-2 country the number is dialled from, or "" for international form only |
| returns | PhoneE164 |
The type it declares, generated into your project
/** Everything but valid and reason is null when valid is false. */
export interface PhoneE164 {
readonly valid: boolean;
/** E.164: "+" then country calling code and national number */
readonly normalised: string | null;
/** "44" */
readonly callingCode: string | null;
/** the national significant number, without trunk prefix: "7700900123" */
readonly nationalNumber: string | null;
/** null when valid; empty, bad-character, bad-format, no-country, unknown-country-code, bad-length or bad-prefix */
readonly reason: string | null;
}
Your code names it in one line, in the file that uses it
import { validatePhoneE164 } from "#fune/validation.phone-e164@^2";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { PHONE_COUNTRIES, type PhoneCountry } from "./validation_phone_e164_data.ts"; ← this capability’s own data, compiled from data/phone-countries.json into the same file by fune build
import { type PhoneE164 } from "./validation_phone_e164_types.ts";
/**
* Characters people put between digits. Brackets matter because of the UK
* habit of writing "+44 (0)20 ...".
*/
const SEPARATORS = " -.()";
function invalid(reason: string): PhoneE164 {
return { valid: false, normalised: null, callingCode: null, nationalNumber: null, reason };
}
function country(code: string): PhoneCountry | undefined {
return PHONE_COUNTRIES.find((row) => row.country === code);
}
/**
* Normalise a phone number to E.164 for a default country.
*
* This is a plausibility check over a small table of countries, not a
* numbering-plan database: it knows each country's calling code, trunk prefix,
* international dialling prefix, national number lengths and the digits a
* national number may start with. It does not know which ranges are
* allocated, or whether a number is a mobile.
*/
export function validatePhoneE164(value: string, defaultCountry: string): PhoneE164 {
// ASCII-only upper-casing, so every language folds identically.
let code = "";
for (const ch of defaultCountry) code += ch >= "a" && ch <= "z" ? String.fromCharCode(ch.charCodeAt(0) - 32) : ch;
let home: PhoneCountry | undefined;
if (code !== "") {
home = country(code);
// A bad default country is the caller's bug, not the user's typo, so it
// is thrown rather than answered.
if (home === undefined) throw new RangeError(`unsupported default country "${defaultCountry}"`);
}
if (typeof value !== "string") return invalid("empty");
let digits = "";
let plus = false;
for (const ch of value) {
if (SEPARATORS.includes(ch)) continue;
if (ch === "+") {
// A plus is only meaningful as the very first thing typed.
if (plus || digits !== "") return invalid("bad-format");
plus = true;
continue;
}
if (ch < "0" || ch > "9") return invalid("bad-character");
digits += ch;
}
if (digits === "") return invalid(plus ? "bad-format" : "empty");
if (!plus && home !== undefined && digits.startsWith(home.internationalPrefix)) {
// Dialled with the home country's international prefix: 00 44 ... from
// the UK, 011 44 ... from North America, 0011 44 ... from Australia.
digits = digits.slice(home.internationalPrefix.length);
plus = true;
}
let target: PhoneCountry | undefined;
let national: string;
if (plus) {
// Calling codes are prefix-free, so the first match is the only one.
for (const size of [1, 2, 3]) {
const head = digits.slice(0, size);
if (home !== undefined && home.callingCode === head) {
target = home;
break;
}
target = PHONE_COUNTRIES.find((row) => row.callingCode === head);
if (target !== undefined) break;
}
if (target === undefined) return invalid("unknown-country-code");
national = digits.slice(target.callingCode.length);
} else {
if (home === undefined) return invalid("no-country");
target = home;
national = digits;
}
// The trunk prefix is dialled only inside the country ("0" in the UK, "1"
// in North America). It is dropped once, in national form and in the
// "+44 (0)20" form alike; no national number starts with it anyway.
if (target.trunkPrefix !== "" && national.startsWith(target.trunkPrefix)) {
national = national.slice(target.trunkPrefix.length);
}
if (national.length < target.minLength || national.length > target.maxLength) return invalid("bad-length");
if (!target.leadingDigits.includes(national[0])) return invalid("bad-prefix");
return {
valid: true,
normalised: "+" + target.callingCode + national,
callingCode: target.callingCode,
nationalNumber: national,
reason: null,
};
}Install
fune build
With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:
fune add validation.phone-e164
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./validation.phone-e164-2.0.0-typescript.fune, or fetch it from a terminal with fune pull validation.phone-e164@2.0.0:typescript.
The whole function, every language, is one file too: validation.phone-e164-2.0.0.fune, 29,949 bytes, sha256 7fb8dd5dbc3e989deda850ffb47f21b2c03c547eb73908c78d864e92d7f09a58. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before validation.phone-e164
after — your function gets the result and the arguments, and returns the final result.
// fune: after validation.phone-e164
replace — it requires no other capability, so there is no dependency to replace.
step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show validation.phone-e164 --steps.
// fune: step validation.phone-e164 after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
| Case | Arguments | Expected | |
|---|---|---|---|
| a UK mobile in national form loses its trunk 0 | 07700 900123, GB | → | valid true, normalised +447700900123, calling code 44, national number 7700900123, reason — |
| an international number ignores the default country | +44 7700 900123, US | → | valid true, normalised +447700900123, calling code 44, national number 7700900123, reason — |
| international form needs no default country | +44 7700 900123, | → | valid true, normalised +447700900123, calling code 44, national number 7700900123, reason — |
| the UK habit of writing (0) after the country code | +44 (0)20 7946 0018, GB | → | valid true, normalised +442079460018, calling code 44, national number 2079460018, reason — |
| 00 is how the UK dials out, so 0044 is international | 0044 20 7946 0018, GB | → | valid true, normalised +442079460018, calling code 44, national number 2079460018, reason — |
| a US number with the area code in brackets | (212) 555-0123, US | → | valid true, normalised +12125550123, calling code 1, national number 2125550123, reason — |
| a US number with the leading 1 trunk prefix | 1-212-555-0123, US | → | valid true, normalised +12125550123, calling code 1, national number 2125550123, reason — |
| 011 is how North America dials out | 011 44 20 7946 0018, US | → | valid true, normalised +442079460018, calling code 44, national number 2079460018, reason — |
| Canada shares +1 with the US | +1 416 555 0199, CA | → | valid true, normalised +14165550199, calling code 1, national number 4165550199, reason — |
| an Australian landline in national form | (02) 9876 5432, AU | → | valid true, normalised +61298765432, calling code 61, national number 298765432, reason — |
Show the other 21 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| 0011 is how Australia dials out, not 00 | 0011 44 7700 900123, AU | → | valid true, normalised +447700900123, calling code 44, national number 7700900123, reason — |
| Italy keeps the leading 0 of a landline after +39 | 06 1234 5678, IT | → | valid true, normalised +390612345678, calling code 39, national number 0612345678, reason — |
| Spain has no trunk prefix | 612 34 56 78, ES | → | valid true, normalised +34612345678, calling code 34, national number 612345678, reason — |
| a French number written in pairs | 01 23 45 67 89, FR | → | valid true, normalised +33123456789, calling code 33, national number 123456789, reason — |
| a Berlin number | 030 12345678, DE | → | valid true, normalised +493012345678, calling code 49, national number 3012345678, reason — |
| an Irish mobile | 087 123 4567, IE | → | valid true, normalised +353871234567, calling code 353, national number 871234567, reason — |
| a New Zealand mobile | 021 123 4567, NZ | → | valid true, normalised +64211234567, calling code 64, national number 211234567, reason — |
| an Indian mobile | 098765 43210, IN | → | valid true, normalised +919876543210, calling code 91, national number 9876543210, reason — |
| a Dutch mobile, with dots | 06.1234.5678, NL | → | valid true, normalised +31612345678, calling code 31, national number 612345678, reason — |
| the default country is read case-insensitively | 07700 900123, gb | → | valid true, normalised +447700900123, calling code 44, national number 7700900123, reason — |
| a UK number one digit too long | 07700 9001234, GB | → | valid false, normalised —, calling code —, national number —, reason bad-length |
| a US number one digit short | 212 555 012, US | → | valid false, normalised —, calling code —, national number —, reason bad-length |
| a North American area code cannot start with 0 | (012) 555-0123, US | → | valid false, normalised —, calling code —, national number —, reason bad-prefix |
| a calling code outside the supported countries | +999 123456, GB | → | valid false, normalised —, calling code —, national number —, reason unknown-country-code |
| a national number with no default country to read it in | 020 7946 0018, | → | valid false, normalised —, calling code —, national number —, reason no-country |
| letters, as in a vanity number | 0800 FLOWERS, GB | → | valid false, normalised —, calling code —, national number —, reason bad-character |
| a plus sign that is not at the start | 44+2079460018, GB | → | valid false, normalised —, calling code —, national number —, reason bad-format |
| a plus sign and nothing else | +, GB | → | valid false, normalised —, calling code —, national number —, reason bad-format |
| the empty string | , GB | → | valid false, normalised —, calling code —, national number —, reason empty |
| separators and nothing else | ( ) -, GB | → | valid false, normalised —, calling code —, national number —, reason empty |
| an unsupported default country is a programming error | 07700 900123, XX | → | error: unsupported default country |
More from the author
It knows twelve countries, and for each only what normalising needs: the calling code, the trunk prefix dialled inside the country, the international prefix dialled to leave it, the range of national number lengths, and which digits a national number may start with. It does not know which ranges are allocated, whether a number is mobile or fixed, or any country's area codes. A pass means "plausibly a number in that country", not "a line that rings". For full numbering-plan validation, use Google's libphonenumber.
Countries: GB, IE, US, CA, FR, DE, ES, IT, NL, AU, NZ, IN. A number in international form with any other calling code is `unknown-country-code`.
## How a number is read
1. Spaces, hyphens, dots and brackets are ignored. A "+" is allowed only at the start. Anything else, including the letters of a vanity number and an extension ("ext 12"), is `bad-character`. 2. A number starting with the default country's international prefix is read as international: 00 in most of Europe and India, 011 in North America, 0011 in Australia. 3. In international form the calling code is matched (calling codes are prefix-free, so there is only ever one match). Otherwise the default country applies, and with no default country the answer is `no-country`. 4. The trunk prefix (0, or 1 in North America) is dropped once if present. That handles "07700 900123" and the UK habit of writing "+44 (0)20 ...". Spain and Italy have no trunk prefix, and in Italy the leading 0 of a landline is part of the number: "06 1234 5678" is +390612345678. 5. The national number's length and first digit are checked against the table. North American area codes cannot start with 0 or 1; that is the only area-code rule applied.
The US and Canada share +1 and the same rules, so which of them a +1 number belongs to is not reported: the result carries the calling code, not a country.
| reason | meaning (checked in this order) | |---|---| | `empty` | no digits and no "+", or not a string | | `bad-character` | a character other than digits, separators and a leading "+" | | `bad-format` | a "+" that is not at the start, or nothing after it | | `no-country` | a national number and no default country | | `unknown-country-code` | a calling code outside the table | | `bad-length` | the national number is too short or too long for the country | | `bad-prefix` | the national number starts with a digit the country never uses |
An unsupported `defaultCountry` is a programming error and throws (`unsupported default country "XX"`); "" means international form only.
## Data
`data/phone-countries.json`. The calling codes are ITU-T E.164 assignments; trunk and international prefixes are those each national regulator publishes. The lengths are deliberately wide plausibility bounds, not the exact lengths per range: GB 9-10 (a few areas still have 9-digit national numbers), IE 7-9, US/CA 10, FR 9, DE 5-13 (German numbers vary in length by area), ES 9, IT 6-11, NL 9, AU 9, NZ 8-10, IN 10. Numbering plans do change; a change is a new version of this data.
## Sources
- ITU-T Recommendation E.164 and the ITU list of country calling codes ("List of ITU-T Recommendation E.164 assigned country codes"): https://www.itu.int/pub/T-SP-E.164D - ITU-T operational bulletin annex, national numbering plans (per country trunk and international prefixes): https://www.itu.int/oth/T0202 - Ofcom, drama numbers used in the vectors (07700 900xxx, 020 7946 0xxx): https://www.ofcom.org.uk/phones-and-broadband/phone-numbers/numbers-for-drama
2.0.0 renames the function from `phoneE164` to `validatePhoneE164` (`validate_phone_e164` in Python and Rust), so every validator that returns a result record is `validateX` and every one that returns a bool is `isX`. Nothing else changed; 1.0.0 stays published under the old name.
Files
| Path | Bytes |
|---|---|
| README.md | 4,086 |
| data/phone-countries.json | 1,849 |
| impl/python.py | 3,967 |
| impl/rust.rs | 5,175 |
| impl/typescript.ts | 3,828 |
| vectors.json | 6,350 |