impl/typescript.ts
3,828 bytes · the TypeScript implementation · view raw
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,
};
}