Functional Weave
Code in Python

validation.phone-e164@1.0.0

impl/typescript.ts

3,820 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 phoneE164(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,
  };
}