Custom JavaScript Form Validation

1

Custom JavaScript Form Validation

▶

Client-side form validation is one of the most important pieces of interactive web development. While HTML5 provides a useful set of built-in validation attributes — required, type, pattern, minlength, min, max, and others — real-world forms quickly outgrow what those attributes can express. Cross-field comparisons (such as confirming a password), conditional rules (validating a field only when another has a certain value), format checks more nuanced than a single regular expression, and fully custom error messages all require JavaScript. Custom validation done well also means the browser's native submission-prevention mechanism still works, errors are displayed inline next to their fields, assistive technologies are kept informed, and the code is organized so that adding or changing a rule is a surgical, localized change rather than a hunt through scattered event handlers. The sections below build up every layer of that picture.

The Constraint Validation API

Modern browsers expose the Constraint Validation API on every form control element (input, textarea, select, and so on). This API is the bridge between your custom JavaScript logic and the browser's own validation machinery. Understanding it is the foundation of everything else.

Every form control has a validity property that returns a live ValidityState object. This object contains a set of boolean flags, each describing a specific category of validation failure. When a flag is true, that particular constraint has been violated.

Flag Set to true when…
valueMissing The field has the required attribute and its value is empty.
typeMismatch The value does not match the format implied by the type attribute (e.g., a non-email value in type="email").
patternMismatch The value does not match the regular expression in the pattern attribute.
tooShort The value length is less than minlength.
tooLong The value length exceeds maxlength.
rangeUnderflow The numeric value is less than the min attribute.
rangeOverflow The numeric value exceeds the max attribute.
stepMismatch The value does not conform to the step attribute increment.
customError A custom error has been set via setCustomValidity().
valid All constraints pass — no flags above are true.

The checkValidity() method evaluates all constraints on a field and returns true if the field is valid or false if any constraint is violated. Crucially, when it returns false it also fires an invalid event on that element. You can listen for that event to coordinate your UI updates. Calling form.checkValidity() runs the check on every field in the form and returns false if any single field fails.

The most powerful method for custom logic is setCustomValidity(message). Passing a non-empty string marks the field as invalid and sets the validity.customError flag to true. The browser will use this string as the validation message in its native tooltip and will prevent form submission. Passing an empty string clears the custom error, setting validity.customError back to false — but only if no other built-in constraint is also failing. This is vital: you must call setCustomValidity('') every time you re-evaluate a field, otherwise a stale custom error will block submission even after the user has corrected the input.

const emailInput = document.getElementById('email');

function validateEmail() {
  const value = emailInput.value.trim();

  // Always clear first so a previously set custom error does not persist
  emailInput.setCustomValidity('');

  if (value === '') {
    emailInput.setCustomValidity('Please enter your email address.');
  } else if (!value.includes('@')) {
    emailInput.setCustomValidity('That does not look like a valid email.');
  }
  // If neither condition fires, the field remains valid
}

emailInput.addEventListener('blur', validateEmail);

Using the Constraint Validation API keeps your custom logic wired directly into the browser's submission-prevention mechanism. You do not need to manually track an "is the form valid" boolean — as long as any field reports invalid (whether through a built-in constraint or a custom one), the browser will refuse to submit the form natively and will fire the invalid event on each failing field.

Intercepting Form Submission with Event Listeners

Even when you use the Constraint Validation API, it is standard practice to attach a submit event listener to the form itself. This gives you a single, central location where you can run a final pass of all validation logic, assemble error state, and decide whether to allow or block submission. It is also the place where you would serialize form data and send it via fetch if you are handling the submission with AJAX rather than a traditional page reload.

Select the form using document.querySelector() or document.getElementById(), then register the handler:

const form = document.querySelector('#registration-form');

form.addEventListener('submit', handleSubmit);

function handleSubmit(event) {
  // Prevent the browser from submitting immediately
  event.preventDefault();

  // Run all custom validation (details in later sections)
  const isValid = validateForm(form);

  if (isValid) {
    // All checks passed — submit programmatically or via fetch
    form.submit(); // native submission
    // — or —
    // submitViaFetch(new FormData(form));
  }
  // If not valid, errors are already displayed; do nothing further here
}

Calling event.preventDefault() at the very top of the handler — before any validation logic — is the safe approach. It ensures the form will never submit until your code explicitly decides it should. After validation, you re-allow submission either by calling form.submit() programmatically (which bypasses the submit event and therefore does not re-trigger your listener) or by sending the data yourself via the Fetch API. An alternative pattern is to let the browser's own Constraint Validation API handle prevention: only call event.preventDefault() when your custom logic finds errors. Either approach works; the explicit prevention pattern is more predictable when mixing built-in and custom checks.

Writing Custom Validation Functions

A key principle of maintainable validation is separating the validation rules from the DOM interaction. Each rule should live in its own small function that receives a value (or values) and returns either null (or true) for a valid result, or an error message string for an invalid result. These functions know nothing about the DOM — they are pure functions of their inputs, which makes them trivial to unit-test and reuse across different forms.

// Single-field validators — pure functions, no DOM access
function isRequired(value) {
  return value.trim().length > 0 ? null : 'This field is required.';
}

function minLength(min) {
  return function(value) {
    return value.trim().length >= min
      ? null
      : `Must be at least ${min} characters.`;
  };
}

function isValidEmail(value) {
  // A more thorough check than type="email" alone
  const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
  return emailPattern.test(value.trim())
    ? null
    : 'Please enter a valid email address (e.g., user@example.com).';
}

function isStrongPassword(value) {
  const hasUpper  = /[A-Z]/.test(value);
  const hasLower  = /[a-z]/.test(value);
  const hasDigit  = /\d/.test(value);
  const hasSymbol = /[!@#$%^&*]/.test(value);
  const longEnough = value.length >= 8;

  if (!longEnough) return 'Password must be at least 8 characters.';
  if (!hasUpper)   return 'Password must include at least one uppercase letter.';
  if (!hasLower)   return 'Password must include at least one lowercase letter.';
  if (!hasDigit)   return 'Password must include at least one number.';
  if (!hasSymbol)  return 'Password must include at least one symbol (!@#$%^&*).';
  return null;
}

Cross-field validation is where custom JavaScript genuinely surpasses what HTML attributes can do. A cross-field rule reads values from two or more inputs and compares them. The validator function can receive the entire form or a collection of values:

function passwordsMatch(password, confirmPassword) {
  return password === confirmPassword
    ? null
    : 'Passwords do not match. Please re-enter your password.';
}

// Called during form validation:
const error = passwordsMatch(
  document.getElementById('password').value,
  document.getElementById('confirm-password').value
);

Format checks that go beyond a single pattern attribute are another strong use case. Consider a US phone number that should accept several formats:

function isValidPhone(value) {
  // Accept (555) 867-5309, 555-867-5309, 5558675309, +15558675309
  const cleaned = value.replace(/\D/g, '');
  if (cleaned.length === 10) return null;
  if (cleaned.length === 11 && cleaned[0] === '1') return null;
  return 'Enter a 10-digit US phone number (e.g., 555-867-5309).';
}

Range and relationship validation is also common — for example, ensuring an end date is after a start date:

function endDateAfterStart(startValue, endValue) {
  if (!startValue || !endValue) return null; // let required checks handle emptiness
  return new Date(endValue) > new Date(startValue)
    ? null
    : 'End date must be later than the start date.';
}

Keeping each function small and single-purpose (the single responsibility principle) means a failing rule is immediately traceable to one function, and adding a new constraint is as simple as writing one new function and registering it in the rules configuration.

Displaying Inline Error Messages

The browser's native validation tooltip (the yellow popup) is accessible in some browsers but visually inconsistent, cannot be styled to match your design, and disappears on the first interaction. Inline error messages — text displayed directly below (or near) each field — are far superior in both usability and visual control.

Reserve a container element in the HTML markup for each field's error message. Using a <span> with a descriptive class and an aria-live attribute is the standard approach:

<div class="form-group">
  <label for="username">Username</label>
  <input type="text" id="username" name="username" required minlength="3" />
  <span class="error-msg" id="username-error" aria-live="polite"></span>
</div>

In JavaScript, write two small helper functions — one to display an error and one to clear it:

function showError(input, message) {
  const errorEl = document.getElementById(`${input.id}-error`);
  if (errorEl) {
    errorEl.textContent = message;
  }
  input.classList.add('input--error');
  input.setAttribute('aria-invalid', 'true');
}

function clearError(input) {
  const errorEl = document.getElementById(`${input.id}-error`);
  if (errorEl) {
    errorEl.textContent = '';
  }
  input.classList.remove('input--error');
  input.setAttribute('aria-invalid', 'false');
}

The corresponding CSS makes the error state visible:

.error-msg {
  display: block;
  color: #d32f2f;
  font-size: 0.85rem;
  margin-top: 4px;
  min-height: 1.2em; /* reserves space so layout does not jump */
}

.input--error {
  border: 2px solid #d32f2f;
  background-color: #fff5f5;
}

Notice that textContent is used instead of innerHTML. This is both a security best practice (no risk of injecting markup from error strings) and a performance detail (no HTML parsing required). Always clear the error container (textContent = '') when validation passes; leaving a stale message in place is a common bug that confuses users.

Real-Time Validation with Input and Blur Events

Timing is everything in form validation UX. Displaying errors while a user is mid-keystroke — before they have had a chance to finish typing — is one of the most common and frustrating validation mistakes. The solution is to choose the right event for each scenario.

The blur event fires when a field loses focus. This is the right moment for most validation: the user has finished entering their value and moved on. Validate on blur to show the first error message without being intrusive:

const usernameInput = document.getElementById('username');

usernameInput.addEventListener('blur', function() {
  const error = isRequired(this.value) || minLength(3)(this.value);
  if (error) {
    showError(this, error);
  } else {
    clearError(this);
  }
});

The input event fires on every change to the value — every keystroke, paste, cut, or autofill. Use this for live feedback where the benefit outweighs the noise: password strength meters, character counters, and clearing existing errors as the user corrects them are all appropriate uses:

const passwordInput = document.getElementById('password');
const strengthIndicator = document.getElementById('password-strength');

passwordInput.addEventListener('input', function() {
  // Clear a previously shown error immediately as the user types
  clearError(this);

  // Live strength feedback (not a hard error, just guidance)
  const strength = measurePasswordStrength(this.value);
  strengthIndicator.textContent = `Strength: ${strength}`;
  strengthIndicator.className = `strength strength--${strength.toLowerCase()}`;
});

The combination strategy — validate (show errors) on blur, and clear errors in real time on input — gives the best of both worlds. Here is the full pattern applied to a single field:

function attachValidation(input, validators) {
  // Show error only after the user leaves the field
  input.addEventListener('blur', function() {
    runValidation(input, validators);
  });

  // Clear the error immediately as the user starts correcting
  input.addEventListener('input', function() {
    // Only clear; do not re-show errors mid-typing
    if (input.classList.contains('input--error')) {
      clearError(input);
    }
  });
}

function runValidation(input, validators) {
  for (const validate of validators) {
    const error = validate(input.value);
    if (error) {
      showError(input, error);
      input.setCustomValidity(error);
      return;
    }
  }
  clearError(input);
  input.setCustomValidity('');
}

For expensive checks — such as querying a server to see whether a username is already taken — never call them on every keystroke. Use debouncing to delay the call until the user has paused typing, and restrict the absolute final check to the blur event or the form submit handler:

function debounce(fn, delay) {
  let timer;
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

const checkUsernameAvailability = debounce(async function(value) {
  if (value.length < 3) return;
  const response = await fetch(`/api/check-username?name=${encodeURIComponent(value)}`);
  const { available } = await response.json();
  if (!available) {
    showError(usernameInput, 'That username is already taken. Please choose another.');
    usernameInput.setCustomValidity('Username taken.');
  } else {
    clearError(usernameInput);
    usernameInput.setCustomValidity('');
  }
}, 400);

usernameInput.addEventListener('input', function() {
  checkUsernameAvailability(this.value);
});

Accessibility Considerations in Custom Validation

A visually obvious red border and error text means nothing to a user who relies on a screen reader unless the DOM communicates that information programmatically. Accessibility is not a finishing touch — it must be built into the validation pattern from the start.

aria-describedby links an input to one or more elements whose content describes it. Screen readers will read the linked element's content when the field is focused. Set it in the HTML (not in JavaScript) so the association exists even before any error occurs:

<input
  type="email"
  id="email"
  name="email"
  required
  aria-describedby="email-error email-hint"
/>
<span id="email-hint" class="hint">We will never share your email address.</span>
<span id="email-error" class="error-msg" aria-live="polite"></span>

When the screen reader focuses the email field, it will announce both the hint and, if present, any error message. Multiple IDs in aria-describedby are space-separated.

aria-invalid explicitly communicates that a field's current value fails validation. Set it to "true" when showing an error and "false" (or remove it entirely) when clearing the error. Screen readers typically announce something like "invalid entry" when the user lands on a field with aria-invalid="true":

// In showError:
input.setAttribute('aria-invalid', 'true');

// In clearError:
input.setAttribute('aria-invalid', 'false');

aria-live on the error container tells the browser to announce changes to that element's content without requiring the user to move focus to it. aria-live="polite" waits for the user to finish their current activity before announcing; aria-live="assertive" interrupts immediately. Polite is almost always the right choice for form validation errors — assertive should be reserved for critical, time-sensitive announcements.

Beyond ARIA attributes, the content of error messages matters for accessibility. A screen reader user who hears "Invalid input" has no actionable information. Every error message should name the field (if not obvious from context), describe what is wrong, and say how to fix it:

Poor message Better message
Invalid input Please enter a valid email address (e.g., user@example.com).
Error Password must be at least 8 characters and include a number.
Required Full name is required.
Passwords don't match Passwords do not match. Please re-enter your password in the confirmation field.

Additionally, when the form fails validation on submit, consider moving focus programmatically to the first invalid field. This allows keyboard and screen reader users to immediately begin correcting errors rather than having to navigate back up to them:

function focusFirstError(form) {
  const firstInvalid = form.querySelector('[aria-invalid="true"]');
  if (firstInvalid) {
    firstInvalid.focus();
  }
}

Structuring a Reusable Validation Module

Ad hoc validation — dozens of if statements scattered across multiple event handlers — becomes unmanageable as forms grow. A structured, configuration-driven approach centralizes all rules into one place and separates the what (which rules apply to which fields) from the how (the loop that applies them).

Start with a rules configuration object that maps field IDs to an ordered array of validator functions. The validators are the pure functions written earlier:

const formRules = {
  username: [
    isRequired,
    minLength(3),
    maxLength(20),
  ],
  email: [
    isRequired,
    isValidEmail,
  ],
  password: [
    isRequired,
    isStrongPassword,
  ],
  'confirm-password': [
    isRequired,
    // Cross-field rule — a closure that reads the password field
    (value) => passwordsMatch(
      document.getElementById('password').value,
      value
    ),
  ],
};

Write a generic validate(fieldId, rules) function that iterates over the rules for that field and returns the first error message it finds, or null if all rules pass. Returning on the first failure (short-circuit evaluation) is deliberate: showing one error at a time per field is less overwhelming than showing all failures simultaneously:

function validate(fieldId, rules) {
  const input = document.getElementById(fieldId);
  if (!input) return null;

  for (const rule of rules) {
    const error = rule(input.value);
    if (error) return error;
  }
  return null;
}

Write a validateForm() function that loops over all entries in the rules configuration, calls validate() for each, and updates the UI. It returns true only if every field passes:

function validateForm() {
  let isValid = true;

  for (const [fieldId, rules] of Object.entries(formRules)) {
    const input = document.getElementById(fieldId);
    const error = validate(fieldId, rules);

    if (error) {
      showError(input, error);
      input.setCustomValidity(error);
      isValid = false;
    } else {
      clearError(input);
      input.setCustomValidity('');
    }
  }

  if (!isValid) {
    focusFirstError(document.getElementById('registration-form'));
  }

  return isValid;
}

Wire everything together in the submit handler and by attaching real-time listeners to each field using the same rules map:

const form = document.getElementById('registration-form');

// Attach blur/input listeners to every configured field
for (const [fieldId, rules] of Object.entries(formRules)) {
  const input = document.getElementById(fieldId);
  if (!input) continue;

  input.addEventListener('blur', () => {
    const error = validate(fieldId, rules);
    if (error) {
      showError(input, error);
      input.setCustomValidity(error);
    } else {
      clearError(input);
      input.setCustomValidity('');
    }
  });

  input.addEventListener('input', () => {
    if (input.classList.contains('input--error')) {
      clearError(input);
      input.setCustomValidity('');
    }
  });
}

form.addEventListener('submit', function(event) {
  event.preventDefault();
  if (validateForm()) {
    form.submit();
  }
});

This architecture has several concrete benefits. Adding a new rule to an existing field is a one-line change in formRules. Adding validation to a new field means adding one entry to formRules and the corresponding HTML. Removing a rule is equally surgical. And because each validator is a pure function, unit testing is straightforward with any test framework — no DOM or browser required:

// Example unit tests (Jest-style pseudocode)
test('isRequired returns null for non-empty string', () => {
  expect(isRequired('hello')).toBeNull();
});

test('isRequired returns error message for empty string', () => {
  expect(isRequired('')).toBe('This field is required.');
});

test('isValidEmail rejects missing TLD', () => {
  expect(isValidEmail('user@example')).not.toBeNull();
});

test('passwordsMatch returns null when values are equal', () => {
  expect(passwordsMatch('Secret1!', 'Secret1!')).toBeNull();
});

test('passwordsMatch returns error when values differ', () => {
  expect(passwordsMatch('Secret1!', 'Different1!')).toBe(
    'Passwords do not match. Please re-enter your password.'
  );
});

Taken together, the Constraint Validation API, a disciplined event-handling strategy, pure validator functions, semantic error display, accessible ARIA attributes, and a configuration-driven module give you a validation system that is robust, maintainable, and genuinely inclusive for all users.

NotesCovers all listed subtopics in depth. Includes practical code examples for every major concept: ValidityState flags (tabulated), setCustomValidity usage, submit interception, pure validator functions, cross-field and format validation, showError/clearError helpers, blur+input event strategy, debouncing, aria-describedby/aria-invalid/aria-live, accessible error message wording (tabulated), and the full reusable module pattern with unit test examples.