DEV Community

Ivan Labasok
Ivan Labasok

Posted on

Validar números de teléfono en JavaScript: por qué tu expresión regular está mal

En corto

Si en tu proyecto hay una expresión regular validando teléfonos, está mal. No «mejorable»: mal. Y lo peor es que falla en silencio — acepta basura y rechaza números perfectamente válidos de usuarios reales que se van sin registrarse.

Te cuento por qué el problema es irresoluble con regex, qué es E.164, cómo usar libphonenumber-js sin los tres errores habituales, y qué columna deberías tener realmente en la base de datos.


1. Por qué tu expresión regular para teléfonos está mal

Esta es, con variaciones, la regex que aparece en casi todos los tutoriales en español:

const regex = /^\+?[0-9]{9,15}$/;
Enter fullscreen mode Exit fullscreen mode

Parece razonable. Veamos qué hace con números reales:

Entrada Qué es Tu regex dice
+34 612 345 678 Móvil español válido ❌ Rechazado (espacios)
+390212345678 Fijo de Milán, el cero es parte del número ✅ Aceptado, pero si «normalizas» quitando el cero lo rompes
+521234567890 México, con el prefijo móvil 1 que ya no se usa ✅ Aceptado, y no se puede llamar
+999999999999 No existe ese código de país ✅ Aceptado
+34612345678901 Español con seis dígitos de más ✅ Aceptado
+1 (213) 373-4253 ext. 42 Número con extensión ❌ Rechazado

Cuatro falsos positivos y dos falsos negativos en seis casos. Y el problema no es que la regex sea mala: es que la pregunta no tiene respuesta regular. La longitud válida depende del país, dentro del país depende del prefijo, y dentro del prefijo depende de si es fijo, móvil o servicio. Son miles de reglas que además cambian: los países reasignan rangos, abren prefijos nuevos y deprecan otros.

Google mantiene un documento entero dedicado a esto, FALSEHOODS.md, con 27 suposiciones falsas sobre teléfonos. Mis favoritas, porque casi todos las hemos cometido:

  • «Los ceros iniciales del formato nacional siempre se pueden descartar en formato internacional.» Falso — Italia los conserva.
  • «Ningún prefijo de un número válido puede ser a su vez un número válido.» Falso.
  • «Un número inválido no llegará a ningún destino.» Falso.
  • «Los números de teléfono son números.» Falso, y esta es la que más daño hace en producción.

2. E.164: el único formato que deberías guardar

E.164 es el estándar de la UIT y es una definición corta: un +, el código de país, y hasta 15 dígitos en total. Sin espacios, sin guiones, sin paréntesis, sin prefijos de marcación nacional.

+34612345678      ← esto
+34 612 34 56 78  ← esto no, es presentación
(612) 345 678     ← esto tampoco, ni siquiera dice de qué país es
Enter fullscreen mode Exit fullscreen mode

La regla mental es sencilla: E.164 es cómo se almacena, el formato bonito es cómo se muestra. Son dos cosas distintas y confundirlas es el origen de la mitad de los bugs de esta categoría. El formato de presentación se calcula al renderizar; nunca se guarda.


3. libphonenumber-js en cuatro líneas

La biblioteca de Google (libphonenumber) es la fuente de verdad, y en JavaScript la versión práctica es libphonenumber-js — una reescritura más pequeña que carga los metadatos reales de numeración.

npm install libphonenumber-js
Enter fullscreen mode Exit fullscreen mode
import { parsePhoneNumberFromString } from 'libphonenumber-js';

const phone = parsePhoneNumberFromString('612 345 678', 'ES');

phone.number;               // '+34612345678'   ← E.164, esto va a la BD
phone.country;              // 'ES'
phone.countryCallingCode;   // '34'
phone.nationalNumber;       // '612345678'
phone.isValid();            // true
phone.formatInternational(); // '+34 612 34 56 78'  ← esto se muestra
phone.formatNational();      // '612 34 56 78'
phone.getURI();              // 'tel:+34612345678'
Enter fullscreen mode Exit fullscreen mode

Tres cosas que conviene saber desde el primer día:

parsePhoneNumberFromString devuelve undefined si no puede parsear, no lanza. Si prefieres excepciones tipadas, existe parsePhoneNumberWithError, que lanza un ParseError con un message legible.

El segundo argumento es el país por defecto, y solo se usa cuando la entrada no viene en formato internacional. Si el usuario escribe +34..., el país sale del número y el argumento se ignora. Si escribe 612345678 a secas, sin ese 'ES' no hay forma de saber de dónde es.

Hay tres paquetes de metadatos y elegir mal te cuesta o peso o exactitud:

import { parsePhoneNumberFromString } from 'libphonenumber-js/min';    // ~80 kB
import { parsePhoneNumberFromString } from 'libphonenumber-js/mobile'; // ~95 kB
import { parsePhoneNumberFromString } from 'libphonenumber-js/max';    // ~145 kB
Enter fullscreen mode Exit fullscreen mode

El import por defecto usa min. Si necesitas saber si un número es móvil o fijo, necesitas max — con min, getType() no te va a servir:

import { parsePhoneNumberFromString } from 'libphonenumber-js/max';

parsePhoneNumberFromString('+34612345678').getType(); // 'MOBILE'
parsePhoneNumberFromString('+34911234567').getType(); // 'FIXED_LINE'
Enter fullscreen mode Exit fullscreen mode

Esto importa de verdad si vas a mandar un SMS: gastar el envío contra un fijo es tirar el dinero, y el error es invisible hasta que miras la factura.


4. isPossible vs isValid: cuál usar y cuándo

Aquí es donde casi todo el mundo elige mal, porque los nombres no ayudan.

import { isValidPhoneNumber, isPossiblePhoneNumber } from 'libphonenumber-js';

isPossiblePhoneNumber('+34 999 999 999');  // true  — la longitud cuadra
isValidPhoneNumber('+34 999 999 999');     // false — ese rango no existe en España
Enter fullscreen mode Exit fullscreen mode
  • isPossible mira solo la longitud para ese país. Es rápido, tolerante y sobrevive mejor al paso del tiempo.
  • isValid comprueba longitud y prefijo contra el plan de numeración. Es estricto y mucho más exacto.

La elección no es de gusto, depende de qué estés protegiendo:

Usa isValid en el formulario de registro, donde quieres frenar erratas antes de gastar un SMS de verificación. Usa isPossible al importar datos existentes o al validar algo que ya está en tu base de datos, porque isValid rechaza números legítimos que se asignaron después de la última actualización de metadatos. Los planes de numeración se amplían constantemente, y tu package-lock.json no.

Ese matiz tiene una consecuencia operativa que conviene asumir: libphonenumber es una dependencia que caduca. Si la fijaste hace tres años, hoy está rechazando clientes reales. Actualízala como actualizarías una base de datos de zonas horarias.

Para mensajes de error útiles existe además:

import { validatePhoneNumberLength } from 'libphonenumber-js';

validatePhoneNumberLength('612', 'ES');        // 'TOO_SHORT'
validatePhoneNumberLength('6123456789', 'ES'); // 'TOO_LONG'
validatePhoneNumberLength('612345678', 'ES');  // undefined → longitud correcta
Enter fullscreen mode Exit fullscreen mode

Decir «te faltan dígitos» convierte mucho mejor que «número inválido».


5. Formatear mientras el usuario escribe

El campo que se va formateando solo mientras tecleas no es cosmética: reduce erratas de forma medible, porque el usuario ve los grupos de dígitos y detecta él mismo el que falta.

import { AsYouType } from 'libphonenumber-js';

new AsYouType('ES').input('612345');   // '612 345'
new AsYouType().input('+3461234');     // '+34 61 23 4'
Enter fullscreen mode Exit fullscreen mode

Un input de React completo, con validación en vivo:

import { useState } from 'react';
import { AsYouType, parsePhoneNumberFromString } from 'libphonenumber-js';

function TelefonoInput({ pais = 'ES', onChange }) {
  const [texto, setTexto] = useState('');

  function handleInput(e) {
    const formateado = new AsYouType(pais).input(e.target.value);
    setTexto(formateado);

    const parsed = parsePhoneNumberFromString(formateado, pais);
    // Al padre le entregamos SIEMPRE E.164, nunca el texto formateado
    onChange(parsed?.isValid() ? parsed.number : null);
  }

  const parsed = parsePhoneNumberFromString(texto, pais);
  const error = texto.length > 3 && !parsed?.isValid();

  return (
    <>
      <input
        type="tel"
        inputMode="tel"
        autoComplete="tel"
        value={texto}
        onChange={handleInput}
        aria-invalid={error}
      />
      {error && <span role="alert">Revisa el número</span>}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Dos detalles de accesibilidad que se olvidan siempre: type="tel" con inputMode="tel" saca el teclado numérico en móvil, y autoComplete="tel" permite que el navegador rellene el campo. Cada uno de los dos vale más que cualquier validación que escribas.

Y una advertencia sobre AsYouType: está pensado para escritura hacia delante. Si el usuario borra por el medio o pega un número entero, el formateo se comporta de forma extraña. La solución habitual es reiniciar el formateador cuando detectas un pegado o un borrado no terminal, en lugar de pelearte con el estado.


6. México, Argentina e Italia: los tres casos que rompen todo

México. Durante años, para llamar a un móvil mexicano desde el extranjero había que intercalar un 1 tras el código de país: +52 1 55 .... Esos prefijos de marcación se deprecaron y se eliminaron del plan. Si tu base de datos se llenó antes del cambio, tienes números guardados que ya no se pueden marcar. Y como pasan el filtro de cualquier regex, nadie se entera hasta que un cliente se queja de que no le llegan los SMS.

Argentina. El caso inverso y aún vivo: los móviles llevan un 9 después del 54 en formato internacional, pero domésticamente ese 9 desaparece y se marca 15 entre el prefijo de zona y el número. +54 9 2982 123456 se marca localmente como 02982 15 123456. Son el mismo teléfono con dos representaciones que no se parecen. Si comparas cadenas para deduplicar contactos, te salen dos personas donde hay una.

Italia. El cero inicial del fijo no se descarta al internacionalizar: es parte del número. +39 02 1234567 es correcto. Si aplicas la regla «quitar el cero inicial» que funciona en España, Francia o Reino Unido, rompes Italia entera.

La conclusión práctica no es memorizar estos tres. Es que existen decenas de casos así y ninguno cabe en tu cabeza ni en tu regex — por eso se delega en una biblioteca con metadatos mantenidos.

// La forma correcta de normalizar es no normalizar a mano:
const e164 = parsePhoneNumberFromString(entrada, paisPorDefecto)?.number ?? null;
Enter fullscreen mode Exit fullscreen mode

7. Validar no es lo mismo que poder llamar

Este es el límite del artículo y conviene decirlo claro: isValid() responde «este número encaja en el plan de numeración», no «este número existe y suena en el bolsillo de alguien».

Un número puede ser perfectamente válido y estar sin asignar, dado de baja, portado a un operador que lo tiene desconectado, o pertenecer a un rango que no acepta SMS. La lista de falsedades de Google lo recoge en dos entradas distintas — «se puede llamar a cualquier número» y «un número inválido no llegará a ningún destino», ambas falsas.

Lo único que demuestra que un número es alcanzable es mandarle algo y ver qué pasa. Si tu producto depende de ello, la verificación real es un código por SMS o una llamada; la validación de formato solo sirve para no desperdiciar esos envíos. Son dos capas y hacen falta las dos.


8. Qué guardar en la base de datos

Tres reglas y una columna que no debes crear.

Guarda E.164 como texto. Nunca como entero: los ceros iniciales desaparecen, el + no cabe, y quince dígitos se salen de un INT. Un VARCHAR(16) sobra.

Guarda el país por separado. Sí, se puede deducir del número, pero es una operación con casos ambiguos —hay códigos de país compartidos por varios territorios, otra de las falsedades de la lista— y lo vas a necesitar para formatear, para elegir idioma y para tarificar. Deducirlo en cada consulta es trabajo repetido.

No guardes el formato de presentación. Se calcula al renderizar con formatInternational(). Si lo guardas, tendrás dos fuentes de verdad que divergirán.

CREATE TABLE contactos (
  id            BIGSERIAL PRIMARY KEY,
  telefono_e164 VARCHAR(16) NOT NULL,   -- '+34612345678'
  pais_iso      CHAR(2)     NOT NULL,   -- 'ES'
  verificado_en TIMESTAMPTZ,            -- NULL = formato válido, alcance NO probado
  CONSTRAINT telefono_e164_formato CHECK (telefono_e164 ~ '^\+[1-9][0-9]{6,14}$')
);

CREATE UNIQUE INDEX ON contactos (telefono_e164);
Enter fullscreen mode Exit fullscreen mode

Fíjate en verificado_en. Esa columna es la que separa «pasó la validación de formato» de «le llegó un SMS y contestó», y tenerlas separadas te ahorra la conversación de soporte más incómoda que existe.

Y sí, ahí arriba hay una expresión regular. Es el CHECK de integridad de E.164 — comprueba que la cadena tiene forma de E.164, no que el número exista. Ese es exactamente el trabajo que una regex sí sabe hacer.


Checklist para llevarte

  • Guarda E.164 como texto, muestra el formato bonito, y no confundas las dos cosas.
  • Nunca una columna numérica para teléfonos.
  • Guarda el país en su propia columna.
  • isValid en el registro; isPossible al importar y al releer datos antiguos.
  • Necesitas el paquete /max si vas a usar getType() para distinguir móvil de fijo.
  • Actualiza libphonenumber periódicamente: los planes de numeración cambian y una versión vieja rechaza clientes reales.
  • No inventes reglas de normalización por país. México, Argentina e Italia ya demuestran por qué.
  • type="tel" + inputMode="tel" + autoComplete="tel". Tres atributos, mucho más impacto que la validación.
  • Validar el formato no es verificar que el número existe. Para eso hace falta mandar algo.

Si has llegado hasta aquí es probable que estés montando algo que además de validar necesita llamar o mandar SMS a esos números, que es donde empieza otra categoría entera de problemas. Escribí sobre esa parte —WebRTC hasta la red telefónica, CDR y el redondeo de facturación— en otro artículo. Y si prefieres no montarlo, Twin Phone resuelve la parte de llamadas y números virtuales; es en lo que trabajo, así que ya sabes con qué descuento leerme.

¿Qué caso raro de numeración te ha roto un formulario? Los de Argentina y México me costaron una tarde cada uno — cuéntame el tuyo en los comentarios.

Top comments (0)