Vérifier un numéro de téléphone et son indicatif

Savoir si un numéro saisi existe dans le plan de numérotation d’un pays, et de quelle sorte de ligne il s’agit.

Pas besoin d’IA

GratuitInstantanéVos données ne sortent pas

Le verdict

N0, parce que la réponse est dans une table publiée, et que la difficulté réelle n’est pas de la lire mais de ne pas inventer ce qui manque. L’extrait refuse un numéro sans indicatif quand l’appelant n’a déclaré aucune région, au lieu de supposer la vôtre : « 0470 12 34 56 » ressort en ligne fixe française ou en mobile belge selon ce qu’on déclare, et les deux sont valides. Le prix de ce niveau est visible et il se paie une fois par an : la table vieillit — un numéro béninois d’avant le passage à dix chiffres est refusé —, et on la met à jour comme n’importe quelle dépendance.

Plan de numérotation de libphonenumber, région déclarée par l’appelant

Pas besoin d’IA

Règle et algorithme classiqueN0GratuitInstantané

Python

snippets/validate-a-phone-number-and-its-country-code/n0.pyTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
"""
Contrôler un numéro de téléphone contre le plan de numérotation de son pays.

Niveau N0. Chaque pays publie les préfixes qui existent, la longueur d'un
numéro derrière chacun d'eux, et la nature de la ligne qu'il atteint. Google
rassemble ces plans dans libphonenumber et les republie à mesure qu'ils
changent — `phonenumbers` ici, `libphonenumber-js` en JavaScript. Dans les deux cas
c'est une consultation de table : pas de réseau, pas de clé, et la même
réponse deux fois.

La seule chose que ce code refuse de faire, c'est deviner. Un numéro écrit
06 12 34 56 78 n'est français que si quelqu'un le dit ; les mêmes chiffres
sont un numéro mobile valide dans plusieurs autres pays. La région est donc
soit écrite dans le numéro, sous la forme du + initial, soit déclarée par
l'appelant, soit le numéro est refusé — jamais supposée d'après l'endroit où
tourne le serveur.

Ce qui revient, c'est le numéro en E.164, la région à laquelle il appartient,
et la nature de la ligne. C'est la nature qui dit à un formulaire qu'un SMS
n'arrivera jamais : en France, un numéro qui commence par 01 est une ligne
fixe, et il est valide.
"""

from __future__ import annotations

import unicodedata

import phonenumbers
from phonenumbers import PhoneNumberFormat, PhoneNumberType

# The enumeration of libphonenumber, by name, so that both languages of this
# page return the same word for the same line.
TYPE_NAMES = {
    value: name
    for name, value in vars(PhoneNumberType).items()
    if isinstance(value, int) and not name.startswith("_")
}

# E.164 caps a number at fifteen digits. Forty characters leaves room for the
# spaces, dots and brackets people type, and refuses a pasted paragraph.
MAX_CHARACTERS = 40

# Everything a phone number may carry besides digits. Digits are any Unicode
# decimal digit, category Nd, which is what the JavaScript side matches too:
# a number typed in Arabic-Indic digits is a number.
PUNCTUATION = "+ .-()/"

# The space characters a word processor and a French keyboard put inside a
# number. libphonenumber has its own list of punctuation it steps over, and the
# narrow no-break space is not in it: a number grouped with those stops being
# read at the first one. They are levelled to an ordinary space, and nothing
# else is touched.
SPACES = "\t\n\r\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000"


def validate_phone_number(raw, *, default_region: str | None = None) -> dict:
    """
    Say whether `raw` is a number that exists in a numbering plan.

    `default_region` is the two-letter country the caller is expecting, and it
    is required unless the number starts with +. There is no fallback: a
    silently assumed region turns a Belgian mobile into a French one, and the
    caller would never see it happen.

    Nothing raises. A number that cannot be read comes back as `valid: False`
    with a `reason`, because a validator that throws in a request path leaves
    the form with nothing to show.
    """
    if not isinstance(raw, str):
        return _report(False, None, None, None, f"a phone number is text, not {type(raw).__name__}")
    if len(raw) > MAX_CHARACTERS:
        return _report(False, None, None, None, f"longer than {MAX_CHARACTERS} characters")

    written = "".join(" " if c in SPACES else c for c in raw).strip(" ")
    # Letters are refused rather than passed on. libphonenumber reads them as
    # the keys of a telephone keypad, so « 06 12 34 56 78 poste 42 » becomes a
    # longer number that is not the one anybody typed.
    if any(c not in PUNCTUATION and unicodedata.category(c) != "Nd" for c in written):
        return _report(False, None, None, None, "contains something that is not a number")
    if not written.startswith("+") and not default_region:
        return _report(False, None, None, None,
                       "no country code in the number and no region declared by the caller")

    try:
        parsed = phonenumbers.parse(written, default_region)
    except phonenumbers.NumberParseException:
        # The two libraries do not word their parse errors the same way, and
        # the caller acts on the refusal, not on its wording.
        return _report(False, None, None, None, "cannot be read as a phone number")

    region = phonenumbers.region_code_for_number(parsed)
    kind = TYPE_NAMES.get(phonenumbers.number_type(parsed))
    kind = None if kind == "UNKNOWN" else kind
    e164 = phonenumbers.format_number(parsed, PhoneNumberFormat.E164)

    if not phonenumbers.is_valid_number(parsed):
        # `is_possible_number` only checks the length, so it separates a number
        # that is too short from one whose prefix is not allocated.
        detail = ("no prefix of that country's plan matches"
                  if phonenumbers.is_possible_number(parsed) else "the wrong length for its country")
        return _report(False, region, kind, e164, f"not in a numbering plan: {detail}")
    return _report(True, region, kind, e164, None)


def _report(valid: bool, region: str | None, kind: str | None,
            e164: str | None, reason: str | None) -> dict:
    return {"valid": valid, "region": region, "type": kind, "e164": e164, "reason": reason}

JavaScript

snippets/validate-a-phone-number-and-its-country-code/n0.jsTesté tel quel — Cet extrait s’exécute avec ses vraies dépendances, et son test tourne à chaque construction du site.
/**
 * Contrôler un numéro de téléphone contre le plan de numérotation de son pays.
 *
 * Niveau N0. Chaque pays publie les préfixes qui existent, la longueur d'un
 * numéro derrière chacun d'eux, et la nature de la ligne qu'il atteint. Google
 * rassemble ces plans dans libphonenumber et les republie à mesure qu'ils
 * changent — `libphonenumber-js` ici, `phonenumbers` en Python. Dans les deux cas
 * c'est une consultation de table : pas de réseau, pas de clé, et la même
 * réponse deux fois.
 *
 * La seule chose que ce code refuse de faire, c'est deviner. Un numéro écrit
 * 06 12 34 56 78 n'est français que si quelqu'un le dit ; les mêmes chiffres
 * sont un numéro mobile valide dans plusieurs autres pays. La région est donc
 * soit écrite dans le numéro, sous la forme du + initial, soit déclarée par
 * l'appelant, soit le numéro est refusé — jamais supposée d'après l'endroit où
 * tourne le serveur.
 *
 * Ce qui revient, c'est le numéro en E.164, la région à laquelle il appartient,
 * et la nature de la ligne. C'est la nature qui dit à un formulaire qu'un SMS
 * n'arrivera jamais : en France, un numéro qui commence par 01 est une ligne
 * fixe, et il est valide.
 */

// The `max` bundle, and not the default one: it is the metadata that carries
// the line types and the full per-country rules, which is what Python's
// `phonenumbers` ships by default.
import { parsePhoneNumberWithError } from 'libphonenumber-js/max';

// E.164 caps a number at fifteen digits. Forty characters leaves room for the
// spaces, dots and brackets people type, and refuses a pasted paragraph.
export const MAX_CHARACTERS = 40;

// Everything a phone number may carry besides digits. Digits are any Unicode
// decimal digit, category Nd, which is what the Python side matches too: a
// number typed in Arabic-Indic digits is a number.
export const PUNCTUATION = '+ .-()/';

// The space characters a word processor and a French keyboard put inside a
// number. libphonenumber has its own list of punctuation it steps over, and
// the narrow no-break space is not in it: a number grouped with those stops
// being read at the first one. They are levelled to an ordinary space, and
// nothing else is touched.
export const SPACES =
  '\t\n\r\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000';

/**
 * Say whether `raw` is a number that exists in a numbering plan.
 *
 * `defaultRegion` is the two-letter country the caller is expecting, and it is
 * required unless the number starts with +. There is no fallback: a silently
 * assumed region turns a Belgian mobile into a French one, and the caller
 * would never see it happen.
 *
 * Nothing throws. A number that cannot be read comes back as `valid: false`
 * with a `reason`, because a validator that throws in a request path leaves
 * the form with nothing to show.
 *
 * @param {string} raw
 * @param {{defaultRegion?: string}} [options]
 */
export function validatePhoneNumber(raw, { defaultRegion } = {}) {
  if (typeof raw !== 'string') {
    return report(false, null, null, null, `a phone number is text, not ${kindOf(raw)}`);
  }
  if (raw.length > MAX_CHARACTERS) {
    return report(false, null, null, null, `longer than ${MAX_CHARACTERS} characters`);
  }

  const written = [...raw].map((c) => (SPACES.includes(c) ? ' ' : c)).join('').replace(/^ +| +$/g, '');
  // Letters are refused rather than passed on. libphonenumber reads them as
  // the keys of a telephone keypad, so “06 12 34 56 78 poste 42” becomes a
  // longer number that is not the one anybody typed.
  if ([...written].some((c) => !PUNCTUATION.includes(c) && !/\p{Nd}/u.test(c))) {
    return report(false, null, null, null, 'contains something that is not a number');
  }
  if (!written.startsWith('+') && !defaultRegion) {
    return report(false, null, null, null,
      'no country code in the number and no region declared by the caller');
  }

  let parsed;
  try {
    parsed = parsePhoneNumberWithError(written, defaultRegion);
  } catch {
    // The two libraries do not word their parse errors the same way, and the
    // caller acts on the refusal, not on its wording.
    return report(false, null, null, null, 'cannot be read as a phone number');
  }

  const region = parsed.country ?? null;
  const kind = parsed.getType() ?? null;
  const e164 = parsed.number;

  if (!parsed.isValid()) {
    // `isPossible` only checks the length, so it separates a number that is
    // too short from one whose prefix is not allocated.
    const detail = parsed.isPossible()
      ? "no prefix of that country's plan matches"
      : 'the wrong length for its country';
    return report(false, region, kind, e164, `not in a numbering plan: ${detail}`);
  }
  return report(true, region, kind, e164, null);
}

const kindOf = (value) => (value === null ? 'null' : typeof value);

const report = (valid, region, type, e164, reason) => ({ valid, region, type, e164, reason });

Essayer

Ce code ne peut pas tourner dans un navigateur. Les sorties ci-dessous sont calculées à la construction du site, en exécutant le vrai code.

Chaque sortie est celle de l’extrait, exécutée à la construction du site. La région déclarée par l’appelant figure dans l’intitulé du cas : l’extrait ne la devine jamais.

  1. Un mobile français, région FR déclarée

    Donné

    06 12 34 56 78

    Ce qu’il renvoie

    { "valid": true, "region": "FR", "type": "MOBILE", "e164": "+33612345678", "reason": null }

    Ce qu’il en conclut

    Numéro valide — FR +33612345678

    Une ligne mobile : un SMS arrivera.

  2. Un fixe français, région FR déclarée

    Donné

    01 23 45 67 89

    Ce qu’il renvoie

    { "valid": true, "region": "FR", "type": "FIXED_LINE", "e164": "+33123456789", "reason": null }

    Ce qu’il en conclut

    Numéro valide — FR +33123456789

    Une ligne fixe : un SMS n’arrivera pas.

  3. Les mêmes chiffres, région BE déclarée

    Donné

    0470 12 34 56

    Ce qu’il renvoie

    { "valid": true, "region": "BE", "type": "MOBILE", "e164": "+32470123456", "reason": null }

    Ce qu’il en conclut

    Numéro valide — BE +32470123456

    Une ligne mobile : un SMS arrivera.

  4. Un numéro sans indicatif, et rien de déclaré

    Donné

    06 12 34 56 78

    Ce qu’il renvoie

    { "valid": false, "region": null, "type": null, "e164": null, "reason": "no country code in the number and no region declared by the caller" }

    Ce qu’il en conclut

    Refusé

    no country code in the number and no region declared by the caller

    Là, ça casseAucune région n’est déclarée et le numéro n’en porte pas : l’extrait refuse au lieu de supposer la France. Le cas juste au-dessus montre pourquoi — « 0470 12 34 56 » est un fixe en France et un mobile en Belgique.

  5. Un numéro béninois à l’ancienne forme, huit chiffres

    Donné

    +229 97 12 34 56

    Ce qu’il renvoie

    { "valid": false, "region": "BJ", "type": null, "e164": "+22997123456", "reason": "not in a numbering plan: no prefix of that country's plan matches" }

    Ce qu’il en conclut

    Refusé

    not in a numbering plan: no prefix of that country's plan matches

    Là, ça casseCe numéro était ordinaire avant le passage du Bénin à dix chiffres ; le plan a changé, et la bibliothèque le refuse. C’est le coût réel de ce niveau : la table est datée, et elle se met à jour comme une dépendance.

  6. Le même numéro béninois à la forme actuelle

    Donné

    +229 01 97 12 34 56

    Ce qu’il renvoie

    { "valid": true, "region": "BJ", "type": "MOBILE", "e164": "+2290197123456", "reason": null }

    Ce qu’il en conclut

    Numéro valide — BJ +2290197123456

    Une ligne mobile : un SMS arrivera.

Ce que ça implique

  • Vos données ne sortent pas
  • Résultat toujours identique
  • Se teste unitairement, cas par cas
  • Dépend d’une bibliothèque
  • Empreinte négligeable

Ce que cela ajoute à votre périmètre réglementaire

  • Aucun périmètre spécifique ajouté : le numéro est contrôlé dans votre processus et n’est transmis à personne
  • Un numéro de téléphone est une donnée personnelle : le valider localement ne le communique pas, l’envoyer à un service de vérification en ligne le communique

Point de rupture

Un plan de numérotation est une donnée datée, pas une règle : « +229 97 12 34 56 » était un numéro béninois ordinaire avant le passage à dix chiffres, et la bibliothèque le refuse aujourd’hui — un préfixe ouvert après la version installée est refusé de la même façon, jusqu’à la mise à jour. Le témoin est dans le même test : « +229 01 97 12 34 56 », la forme actuelle, passe.

Quand passer au niveau suivant

Il n’y a pas de niveau au-dessus. Le jour où il vous faut savoir que la ligne est attribuée et joignable, ou quel opérateur la porte après portabilité, la réponse est une interrogation de réseau facturée chez un opérateur, et elle fait sortir le numéro de votre infrastructure.

Les quatre options

  1. RecommandéN0Plan de numérotation de libphonenumber, région déclarée par l’appelantGratuit · Instantané
  2. N1Niveau sans objet ici
  3. N2Niveau sans objet ici
  4. N3Niveau sans objet ici

Les autres options

N1Niveau sans objet ici

Niveau sans objet ici

Modèle classique légerN1

Il n’y a rien à apprendre, et l’argument se retourne contre le modèle. Ce qu’un classifieur entraîné sur des numéros étiquetés approcherait, c’est une table publiée par des régulateurs — et le seul défaut de cette table, qu’elle vieillit, deviendrait pire : une bibliothèque se met à jour d’une commande, un modèle appris se réentraîne et se redéploie.

N2Niveau sans objet ici

Niveau sans objet ici

Petit modèle spécialisé auto-hébergéN2

Même argument, avec un service à tenir en plus, et une objection propre à ce besoin : la validation d’un numéro est un contrôle de formulaire, appelé à chaque frappe ou à chaque soumission. Ce qu’on y cherche est une réponse immédiate et rejouable, pas un score qui demande un modèle en mémoire et une inférence par appel.

N3Niveau sans objet ici

Niveau sans objet ici

API de LLM généralisteN3

La seule chose qu’un modèle généraliste ajouterait serait de deviner la région manquante d’après le contexte — l’adresse, la langue, le reste du formulaire. C’est exactement ce que cette fiche refuse : une région supposée fait entrer un numéro belge dans le plan français, la sortie est bien formée, et personne ne voit l’erreur. Sa connaissance des plans est en outre figée à son entraînement, sans qu’il puisse dire à quelle date, là où une bibliothèque annonce sa version.

Pour aller plus loin

Source des chiffres

Métadonnées