Functional Weave
Code in Rust

validation.phone-e164

Normalise a phone number to E.164 (+447700900123) using a default country's trunk and dialling prefixes.

1.0.0 (not the latest) · 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

  • phone_e164(07700 900123, GB) → valid true, normalised +447700900123, calling code 44, national number 7700900123, reason — a UK mobile in national form loses its trunk 0
  • phone_e164(+44 7700 900123, US) → valid true, normalised +447700900123, calling code 44, national number 7700900123, reason — an international number ignores the default country
  • phone_e164(+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.

pub fn phone_e164(value: &str, default_country: &str) -> PhoneE164
valuestringas typed: "07700 900123", "+44 (0)20 7946 0018", "(212) 555-0123"
default_countrystringISO 3166 alpha-2 country the number is dialled from, or "" for international form only
returnsPhoneE164

The type it declares, generated into your project

/// Everything but valid and reason is null when valid is false.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PhoneE164 {
    pub valid: bool,
    /// E.164: "+" then country calling code and national number
    pub normalised: Option<String>,
    /// "44"
    pub calling_code: Option<String>,
    /// the national significant number, without trunk prefix: "7700900123"
    pub national_number: Option<String>,
    /// null when valid; empty, bad-character, bad-format, no-country, unknown-country-code, bad-length or bad-prefix
    pub reason: Option<String>,
}

Your code names it in one line, in the file that uses it

fune!(validation.phone-e164@^1);  // then call phone_e164(…)
impl/rust.rs · 146 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::validation_phone_e164_data::{PhoneCountry, PHONE_COUNTRIES};  ← this capability’s own data, compiled from data/phone-countries.json into the same file by fune build

/// Characters people put between digits. Brackets matter because of the UK
/// habit of writing "+44 (0)20 ...".
const SEPARATORS: &str = " -.()";

fn invalid(reason: &str) -> PhoneE164 {
    PhoneE164 {
        valid: false,
        normalised: None,
        calling_code: None,
        national_number: None,
        reason: Some(reason.to_string()),
    }
}

/// 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.
///
/// # Panics
/// Panics if `default_country` is neither "" nor a supported country.
pub fn phone_e164(value: &str, default_country: &str) -> PhoneE164 {
    // ASCII-only upper-casing, so every language folds identically.
    let code = default_country.to_ascii_uppercase();
    let mut home: Option<&PhoneCountry> = None;
    if !code.is_empty() {
        home = PHONE_COUNTRIES.iter().find(|row| row.country == code);
        // A bad default country is the caller's bug, not the user's typo, so
        // it panics rather than answering.
        if home.is_none() {
            panic!("unsupported default country \"{}\"", default_country);
        }
    }

    let mut digits = String::new();
    let mut plus = false;
    for ch in value.chars() {
        if SEPARATORS.contains(ch) {
            continue;
        }
        if ch == '+' {
            // A plus is only meaningful as the very first thing typed.
            if plus || !digits.is_empty() {
                return invalid("bad-format");
            }
            plus = true;
            continue;
        }
        if !ch.is_ascii_digit() {
            return invalid("bad-character");
        }
        digits.push(ch);
    }
    if digits.is_empty() {
        return invalid(if plus { "bad-format" } else { "empty" });
    }

    if let Some(h) = home {
        if !plus && digits.starts_with(h.international_prefix) {
            // Dialled with the home country's international prefix: 00 44 ...
            // from the UK, 011 44 ... from North America, 0011 44 ... from
            // Australia.
            digits = digits[h.international_prefix.len()..].to_string();
            plus = true;
        }
    }

    let target: &PhoneCountry;
    let mut national: &str;
    if plus {
        let mut found: Option<&PhoneCountry> = None;
        // Calling codes are prefix-free, so the first match is the only one.
        for size in 1..=3usize {
            let head = &digits[..size.min(digits.len())];
            if let Some(h) = home {
                if h.calling_code == head {
                    found = Some(h);
                    break;
                }
            }
            found = PHONE_COUNTRIES.iter().find(|row| row.calling_code == head);
            if found.is_some() {
                break;
            }
        }
        target = match found {
            Some(t) => t,
            None => return invalid("unknown-country-code"),
        };
        national = &digits[target.calling_code.len()..];
    } else {
        target = match home {
            Some(h) => h,
            None => return invalid("no-country"),
        };
        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.trunk_prefix.is_empty() && national.starts_with(target.trunk_prefix) {
        national = &national[target.trunk_prefix.len()..];
    }

    let length = national.len() as i64;
    if length < target.min_length || length > target.max_length {
        return invalid("bad-length");
    }
    if !target.leading_digits.contains(&national[0..1]) {
        return invalid("bad-prefix");
    }
    PhoneE164 {
        valid: true,
        normalised: Some(format!("+{}{}", target.calling_code, national)),
        calling_code: Some(target.calling_code.to_string()),
        national_number: Some(national.to_string()),
        reason: None,
    }
}

/// Object keys are camelCase to match the shared vectors, and so that a
/// capability building on this one can reuse the same shape.
pub fn phone_e164_to_value(result: &PhoneE164) -> Value {
    let text = |field: &Option<String>| match field {
        Some(s) => Value::str(s),
        None => Value::Null,
    };
    Value::obj(vec![
        ("valid", Value::Bool(result.valid)),
        ("normalised", text(&result.normalised)),
        ("callingCode", text(&result.calling_code)),
        ("nationalNumber", text(&result.national_number)),
        ("reason", text(&result.reason)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    phone_e164_to_value(&phone_e164(args[0].as_str(), args[1].as_str()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add validation.phone-e164
Download for Rust validation.phone-e164-1.0.0-rust.fune · 21,465 bytes sha256 27bc6c57ccadca63a89c0a64f5454647a12d470459269c095b1bb27416ece7fb

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./validation.phone-e164-1.0.0-rust.fune, or fetch it from a terminal with fune pull validation.phone-e164@1.0.0:rust.

The whole function, every language, is one file too: validation.phone-e164-1.0.0.fune, 29,600 bytes, sha256 c9e8389e8f9ead978a148c80af56bb327ad7c41d0cf134b5bd9b72e1c59e3440. 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.

CaseArgumentsExpected
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
CaseArgumentsExpected
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

Files

PathBytes
README.md3,803
data/phone-countries.json1,849
impl/python.py3,958
impl/rust.rs5,157
impl/typescript.ts3,820
vectors.json6,350