Cómo validar formularios con JavaScript: campos, emails y errores en tiempo real

Valida campos obligatorios, emails, teléfonos y DNI español con feedback visual inmediato. Sin librerías, sin frameworks.

Tienes el formulario montado con HTML y CSS. Se ve bien. Pero cuando el usuario hace clic en "Enviar" sin rellenar nada, o escribe "asdf" como email, el formulario lo acepta igual. Eso es lo que vamos a arreglar.

La validación con JavaScript te da control total: decides qué mensajes mostrar, cuándo mostrarlos y cómo resaltarlos visualmente. El atributo required de HTML hace algo parecido, pero con mensajes genéricos del navegador que no puedes personalizar y que cambian según el sistema operativo. Para cualquier proyecto real, no es suficiente.

En este tutorial vas a construir la validación de un formulario de contacto desde cero: campos obligatorios, email con regex, feedback visual en tiempo real, y al final las funciones para validar teléfono y DNI español que siempre acaban haciendo falta.

La estructura HTML que necesitas

La clave es añadir un <span> vacío después de cada campo. Ahí es donde JavaScript va a meter los mensajes de error. Sin esos spans, no hay dónde mostrar nada.

HTML 📋
<form id="contact-form">
    <div class="form-group">
        <label for="name">Nombre:</label>
        <input type="text" id="name" name="name" placeholder="Tu nombre completo">
        <span class="error-message" id="name-error"></span>
    </div>

    <div class="form-group">
        <label for="email">Email:</label>
        <input type="email" id="email" name="email" placeholder="tu@email.com">
        <span class="error-message" id="email-error"></span>
    </div>

    <div class="form-group">
        <label for="message">Mensaje:</label>
        <textarea id="message" name="message" rows="5"></textarea>
        <span class="error-message" id="message-error"></span>
    </div>

    <button type="submit">Enviar</button>
    <div id="success-message" style="display:none;">Mensaje enviado</div>
</form>

Dos cosas importantes: quita los atributos required si los tenías — JavaScript se encarga ahora. Y fíjate en el patrón de los IDs: el campo tiene id="name" y su span de error tiene id="name-error". Esa convención nos va a simplificar mucho el código después.

CSS para el feedback visual

Añade esto a tu hoja de estilos. Son cuatro reglas: el mensaje de error en rojo, el campo con error, el campo válido, y el mensaje de éxito.

CSS 📋
.error-message {
    display: block;
    color: #ef4444;
    font-size: 14px;
    margin-top: 5px;
    min-height: 20px; /* evita que el layout salte al aparecer */
}

input.error,
textarea.error {
    border-color: #ef4444 !important;
    background-color: #fef2f2;
}

input.success,
textarea.success {
    border-color: #10b981 !important;
    background-color: #f0fdf4;
}

#success-message {
    background-color: #10b981;
    color: white;
    padding: 15px;
    border-radius: 5px;
    margin-top: 20px;
    text-align: center;
    font-weight: bold;
}

El min-height en .error-message es un detalle que se olvida siempre: sin él, cuando aparece el mensaje de error la página da un salto porque el span pasa de 0px a tener contenido. Con esa línea el espacio ya está reservado.

El JavaScript: validación al enviar

Primero la versión básica, que valida cuando el usuario hace clic en Enviar. Después añadimos la validación en tiempo real.

validation.js 📋
document.addEventListener('DOMContentLoaded', function() {
    const form = document.getElementById('contact-form');

    form.addEventListener('submit', function(e) {
        e.preventDefault();
        if (validateForm()) {
            document.getElementById('success-message').style.display = 'block';
        }
    });
});

function validateForm() {
    clearErrors();
    let isValid = true;

    if (!validateName())    isValid = false;
    if (!validateEmail())   isValid = false;
    if (!validateMessage()) isValid = false;

    return isValid;
}

function validateName() {
    const name = document.getElementById('name').value.trim();

    if (name === '') {
        showError('name', 'El nombre es obligatorio');
        return false;
    }
    if (name.length < 2) {
        showError('name', 'El nombre debe tener al menos 2 caracteres');
        return false;
    }

    showSuccess('name');
    return true;
}

function validateEmail() {
    const email = document.getElementById('email').value.trim();
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

    if (email === '') {
        showError('email', 'El email es obligatorio');
        return false;
    }
    if (!emailRegex.test(email)) {
        showError('email', 'Introduce un email válido (ejemplo: nombre@dominio.com)');
        return false;
    }

    showSuccess('email');
    return true;
}

function validateMessage() {
    const message = document.getElementById('message').value.trim();

    if (message === '') {
        showError('message', 'El mensaje es obligatorio');
        return false;
    }
    if (message.length < 10) {
        showError('message', 'El mensaje es demasiado corto (mínimo 10 caracteres)');
        return false;
    }

    showSuccess('message');
    return true;
}

// Helpers — estos tres los vas a reutilizar en cualquier proyecto

function showError(fieldId, message) {
    const field = document.getElementById(fieldId);
    const errorEl = document.getElementById(fieldId + '-error');
    field.classList.add('error');
    field.classList.remove('success');
    errorEl.textContent = message;
}

function showSuccess(fieldId) {
    const field = document.getElementById(fieldId);
    const errorEl = document.getElementById(fieldId + '-error');
    field.classList.add('success');
    field.classList.remove('error');
    errorEl.textContent = '';
}

function clearErrors() {
    document.querySelectorAll('.error-message').forEach(el => el.textContent = '');
    document.querySelectorAll('input, textarea').forEach(field => {
        field.classList.remove('error', 'success');
    });
    document.getElementById('success-message').style.display = 'none';
}

Fíjate en que showError y showSuccess siguen la convención de IDs que establecimos en el HTML: reciben el ID del campo y construyen el ID del error añadiendo -error. Eso evita repetir código y hace que añadir un campo nuevo sea copiar tres líneas.

Validación en tiempo real con blur

Validar solo al enviar funciona, pero no es lo que esperan los usuarios hoy. Lo natural es que el campo se ponga verde o rojo en cuanto el usuario sale de él.

El evento para eso es blur, que se dispara cuando el campo pierde el foco. Aquí es donde la mayoría mete la pata: usan input en vez de blur, y el usuario ve el error de "email inválido" mientras está escribiendo la primera letra. Con blur esperas a que termine.

JavaScript — añadir al final de validation.js 📋
// Añade esto dentro del DOMContentLoaded, después del listener del submit

document.getElementById('name').addEventListener('blur', validateName);
document.getElementById('email').addEventListener('blur', validateEmail);
document.getElementById('message').addEventListener('blur', validateMessage);

// Opcional: quitar el verde cuando el usuario vuelve a editar
['name', 'email', 'message'].forEach(function(id) {
    document.getElementById(id).addEventListener('focus', function() {
        this.classList.remove('success');
        document.getElementById(id + '-error').textContent = '';
    });
});

Con esto, cada campo valida de forma independiente al salir. Si quieres validar mientras escribe para algún campo concreto (el contador de caracteres en un textarea, por ejemplo), cambia ese blur por input — pero solo para ese campo.

Validar teléfono español

Los móviles empiezan por 6 o 7, los fijos por 8 o 9, nueve dígitos en total. La función también acepta espacios y guiones porque la gente los escribe así:

JavaScript 📋
function validatePhone() {
    const raw     = document.getElementById('phone').value;
    const cleaned = raw.replace(/[\s\-]/g, ''); // elimina espacios y guiones
    const regex   = /^[6789]\d{8}$/;

    if (cleaned === '') {
        showError('phone', 'El teléfono es obligatorio');
        return false;
    }
    if (!regex.test(cleaned)) {
        showError('phone', 'Teléfono inválido (9 dígitos, empieza por 6, 7, 8 o 9)');
        return false;
    }

    showSuccess('phone');
    return true;
}

Validar DNI español con la letra

Aquí está el error que he visto en más proyectos reales: solo validan el formato (8 dígitos + letra) pero no comprueban que la letra sea correcta. La letra del DNI se calcula con módulo 23 sobre los 8 números. Si alguien escribe 12345678A y la letra correcta es Z, una validación superficial lo acepta.

JavaScript 📋
function validateDNI() {
    const dni    = document.getElementById('dni').value.trim().toUpperCase();
    const regex  = /^\d{8}[A-Z]$/;
    const letras = 'TRWAGMYFPDXBNJZSQVHLCKE';

    if (dni === '') {
        showError('dni', 'El DNI es obligatorio');
        return false;
    }
    if (!regex.test(dni)) {
        showError('dni', 'Formato incorrecto. Ejemplo: 12345678Z');
        return false;
    }

    const numero         = parseInt(dni.substring(0, 8));
    const letraCorrecta  = letras[numero % 23];
    const letraEscrita   = dni.charAt(8);

    if (letraEscrita !== letraCorrecta) {
        showError('dni', `La letra no es correcta (debería ser ${letraCorrecta})`);
        return false;
    }

    showSuccess('dni');
    return true;
}

Una advertencia importante: esta validación es del lado del cliente. Alguien con conocimientos técnicos puede saltársela desactivando JavaScript. Siempre valida también en el servidor antes de procesar datos críticos.

HTML required vs JavaScript: cuándo usar cada uno

La respuesta corta: required de HTML para formularios simples donde no te importa el aspecto de los mensajes. JavaScript cuando quieres control total sobre los mensajes, los estilos, el momento en que aparecen y la lógica de validación.

Lo que no debes hacer es depender solo de uno de los dos para seguridad. JavaScript puede desactivarse. required no llega al servidor. La validación real está en el backend — lo que hacemos aquí es mejorar la experiencia del usuario, no sustituir la seguridad.

Si necesitas crear campos del formulario dinámicamente (añadir más campos con un botón, por ejemplo), echa un vistazo al tutorial de createElement — es exactamente lo que necesitarías para el siguiente paso.

Formulario de contacto básico con HTML y CSS

El punto de partida para aplicar todo lo que has visto aquí.

¿Quieres crear tu primera app real?

Estoy preparando una guía completa de prompts para construir y desplegar apps con IA. Déjame tu email y te aviso cuando esté lista.