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
"""
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
/**
* 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.
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.
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.
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.
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.
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.
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
- RecommandéN0Plan de numérotation de libphonenumber, région déclarée par l’appelantGratuit · Instantané
- N1Niveau sans objet ici
- N2Niveau sans objet ici
- 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
- libphonenumber — la bibliothèque de Google et les plans qu’elle porte
- phonenumbers — le portage Python, dépôt du projet
- libphonenumber-js — le portage JavaScript, dépôt du projet
- UIT-T E.164 — le plan de numérotation international
Source des chiffres
- phonenumbers sur PyPI (consulté) : version 9.0.39, publiée le 10 septembre 2026, licence Apache 2.0
- libphonenumber-js, dépôt du projet (consulté) : version 1.13.13, publiée le 10 septembre 2026, licence MIT, aucune dépendance ; le lot « max » est celui qui porte les natures de ligne
- libphonenumber, dépôt du projet (consulté) : les métadonnées par pays et leur rythme de publication
- ARCEP Bénin (consulté) : « À partir du 30 novembre 2024, les numéros de téléphone au Bénin passent de 08 à 10 chiffres », avec le préfixe 01 — c’est ce changement qui fait refuser l’ancienne forme
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.