JavaScript 81 views

Smart Form Validation with Custom Messages

Implement a reusable form validation snippet for better user feedback on error states.

By TWC Team • Feb 02, 2026

Code

/**
 * Smart Form Validation with Custom Messages
 * Reusable, accessible vanilla-JS validation that shows inline + summary errors and supports async rules.
 * Usage: attachSmartValidation(document.querySelector('#myForm'), { customMessages, rules, onValidSubmit }).
 */
(() => {
  'use strict';

  const byId = (id) => document.getElementById(id);

  const defaultMessageFor = (field, validity) => {
    const label = field.getAttribute('data-label') || field.name || field.id || 'This field';
    if (validity.valueMissing) return `${label} is required.`;
    if (validity.typeMismatch) return `Please enter a valid ${field.type}.`;
    if (validity.tooShort) return `${label} must be at least ${field.minLength} characters.`;
    if (validity.patternMismatch) return `Please match the requested format for ${label}.`;
    return `Please check ${label}.`;
  };

  const getFocusableFields = (form) =>
    [...form.elements].filter((el) => el && el.name && !el.disabled && !['submit', 'button', 'fieldset'].includes(el.type));

  const upsertInlineError = (field, message) => {
    const id = `${field.id || field.name}-error`;
    let node = byId(id);
    if (!node) {
      node = document.createElement('div');
      node.id = id;
      node.className = 'field-error';
      node.setAttribute('role', 'alert'); // Screen readers announce changes
      field.insertAdjacentElement('afterend', node);
    }
    node.textContent = message || '';
    field.setAttribute('aria-invalid', message ? 'true' : 'false');
    field.setAttribute('aria-describedby', message ? id : '');
  };

  const displayErrors = (errors, errorListEl) => {
    if (!errorListEl) return;
    errorListEl.innerHTML = '';
    Object.values(errors).forEach((msg) => {
      const li = document.createElement('li');
      li.textContent = msg;
      errorListEl.appendChild(li);
    });
  };

  // Reusable form validation function (sync + optional async per-field rules)
  const validateForm = async (form, { customMessages = {}, rules = {}, errorListEl = byId('error-list') } = {}) => {
    try {
      const errors = {};
      const fields = getFocusableFields(form);

      // Validate all fields; async rules run in parallel for performance.
      const asyncChecks = fields.map(async (field) => {
        const name = field.name;
        const value = field.value?.trim?.() ?? '';

        // Basic required check (mirrors native constraint validation)
        if (field.required && !value) {
          errors[name] = customMessages[name]?.required || `${name} is required!`;
          return;
        }

        // Native constraint checks (type, minlength, pattern, etc.)
        if (!field.checkValidity()) {
          errors[name] = customMessages[name]?.invalid || defaultMessageFor(field, field.validity);
          return;
        }

        // Optional async/sync custom rule: (value, field, form) => string | null | Promise<string|null>
        if (typeof rules[name] === 'function') {
          const result = await rules[name](value, field, form);
          if (typeof result === 'string' && result.trim()) errors[name] = result.trim();
        }
      });

      await Promise.all(asyncChecks);

      // Inline errors + summary list
      fields.forEach((field) => upsertInlineError(field, errors[field.name] || ''));
      displayErrors(errors, errorListEl);

      return Object.keys(errors).length === 0;
    } catch (err) {
      console.error('Validation error:', err);
      displayErrors({ _form: 'Something went wrong while validating. Please try again.' }, byId('error-list'));
      return false;
    }
  };

  const attachSmartValidation = (form, options = {}) => {
    if (!(form instanceof HTMLFormElement)) throw new TypeError('attachSmartValidation: form must be an HTMLFormElement.');

    const onSubmit = async (e) => {
      e.preventDefault(); // Prevent default to validate first
      const isValid = await validateForm(form, options);
      if (!isValid) return;

      try {
        // Optional async hook before submission (e.g., analytics, server-side precheck)
        if (typeof options.onValidSubmit === 'function') await options.onValidSubmit(form);
        form.submit(); // Proceed to submit the form
      } catch (err) {
        console.error('Submit hook error:', err);
        displayErrors({ _submit: 'Unable to submit right now. Please try again.' }, options.errorListEl || byId('error-list'));
      }
    };

    // Real-time feedback: validate the changed field only (fast path)
    const onInput = (e) => {
      const field = e.target;
      if (!field?.name || field.disabled) return;
      if (field.checkValidity()) upsertInlineError(field, ''); // Clear when valid; async rules run on submit to avoid spamming
    };

    form.addEventListener('submit', onSubmit);
    form.addEventListener('input', onInput);

    return () => {
      form.removeEventListener('submit', onSubmit);
      form.removeEventListener('input', onInput);
    };
  };

  // Example usage:
  // const detach = attachSmartValidation(document.getElementById('myForm'), {
  //   customMessages: { email: { required: 'Email is required.' } },
  //   rules: { email: async (value) => (value.endsWith('@example.com') ? null : 'Use an @example.com email.') },
  //   onValidSubmit: async () => { /* e.g., await fetch('/track', { method: 'POST' }) */ }
  // });

  // Auto-attach if present (keeps snippet drop-in friendly without leaking globals)
  const form = byId('myForm');
  if (form) attachSmartValidation(form);
})();
Back to Snippets