Skip to main content

πŸ›‘οΈ Form Validation with JavaScript

Validation is the bouncer at the door of your application. Done well, it stops bad data at the entrance and gently guides users to fix their mistakes; done badly, it frustrates people or lets corrupt data slip through. This lesson builds a validation system that is robust, real-time, and accessible to everyone.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain why both client-side and server-side validation are required, and what each is for
  • Use HTML5 validation attributes and know their limits
  • Drive validation from the Constraint Validation API β€” validity, setCustomValidity(), checkValidity()
  • Implement real-time, cross-field, and asynchronous validation
  • Write accessible error messages using ARIA attributes and live regions

Estimated Time: 40–50 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a registration form with live validation, a password-match check, and screen-reader-friendly errors.

In This Lesson

What Validation Is For

Form validation is the process of checking user input against a set of rules before you act on it. It serves four goals at once:

  • Data integrity β€” your database only ever receives well-formed values.
  • Security β€” malformed or malicious input is rejected before it can cause harm.
  • User experience β€” people get immediate, specific feedback instead of a cryptic failure after submitting.
  • Efficiency β€” obvious mistakes are caught in the browser before wasting a server round-trip.
πŸ’‘ The bouncer analogy. Validation is the bouncer at an exclusive club. It checks IDs, enforces the dress code, and turns away anyone who doesn't meet the rules. Without a bouncer, anyone gets in and the venue β€” your data β€” descends into chaos.
flowchart TD A[User input] --> B{Valid?} B -->|Yes| C[Process the data] B -->|No| D[Show a specific error] D --> E[User corrects the field] E --> A

Client-Side vs. Server-Side

This is the single most important idea in the lesson: client-side validation is for user experience; server-side validation is for security. You need both, and one never replaces the other.

Client-side (browser)Server-side (backend)
Instant feedback as the user typesThe final, authoritative check
Improves the experience, cuts server loadCannot be bypassed by the user
Can be disabled or tampered withHandles checks needing the database (e.g. "is this email taken?")
Fast β€” no network round-tripSlower β€” requires a request

⚠️ Never trust the client

Anyone can open dev tools, delete your required attributes, or send a request straight to your API bypassing the form entirely. Client-side validation is a convenience for honest users β€” it is not a security boundary. The server must re-validate everything.

Think of airport security. Client-side validation is the quick boarding-pass glance at the entrance β€” fast and convenient, but forgeable. Server-side validation is the X-ray and metal detector β€” thorough, hard to bypass, and the check that actually keeps you safe. Both layers exist for a reason.

HTML5 Built-in Validation

Before writing a line of JavaScript, the browser already gives you a validation layer for free through input attributes. Reach for these first β€” they are accessible, require no code, and work even if scripts fail.

<form>
  <!-- Required field -->
  <input type="text" name="username" required
         minlength="3" maxlength="15">

  <!-- Type-based validation -->
  <input type="email" name="email" required>
  <input type="url"   name="website">
  <input type="tel"   name="phone">

  <!-- Numeric range -->
  <input type="number" name="age" min="18" max="120">

  <!-- Regex pattern -->
  <input type="text" name="zip" pattern="[0-9]{5}"
         title="Five-digit ZIP code">

  <button type="submit">Sign up</button>
</form>

The title on a pattern field is shown in the browser's default error bubble and read by screen readers, so always describe the expected format there.

πŸ“– The novalidate escape hatch

Add novalidate to a <form> to switch off the browser's automatic pop-up bubbles while keeping the underlying validity state available to JavaScript. This is the standard move when you want custom-styled error messages but still want to read input.validity β€” the best of both worlds.

HTML5 validation is like a pre-installed home alarm: great baseline protection with zero setup. But it can't check that two passwords match, show a strength meter, or ask the server whether a username is free. For those you need JavaScript β€” built on top of the same validity model.

The Constraint Validation API

Rather than reinventing validity from scratch with regexes, lean on the Constraint Validation API that every input already exposes. It bridges HTML5 attributes and your custom logic.

Reading validity

const email = document.querySelector('#email');

email.validity.valid;         // overall: true / false
email.validity.valueMissing;  // required but empty?
email.validity.typeMismatch;  // wrong type (bad email/url)?
email.validity.patternMismatch; // fails the pattern attribute?
email.validity.tooShort;      // below minlength?
email.validity.rangeOverflow; // above max?

email.validationMessage;      // the browser's message text

Setting your own errors

setCustomValidity() lets you mark a field invalid with your own message. Passing an empty string clears it β€” and you must clear it, or the field stays invalid forever.

const password = document.querySelector('#password');
const confirm = document.querySelector('#confirm');

function checkMatch() {
  if (confirm.value !== password.value) {
    confirm.setCustomValidity('Passwords do not match');
  } else {
    confirm.setCustomValidity('');   // clears the error
  }
}

password.addEventListener('input', checkMatch);
confirm.addEventListener('input', checkMatch);

Checking the whole form

const form = document.querySelector('#signup');

form.addEventListener('submit', (event) => {
  if (!form.checkValidity()) {
    event.preventDefault();
    form.reportValidity();  // shows the browser's error UI
  }
});

checkValidity() returns a boolean; reportValidity() does the same but also surfaces the error to the user. Using the API means your custom rules and the browser's built-in ones live in one consistent system.

Real-Time & Cross-Field Checks

Users hate discovering ten errors only after they hit submit. Real-time validation gives feedback as they go β€” but timing matters. Validating every keystroke from the very first character is nagging. The proven pattern is validate on blur, then re-validate on input once the field has been touched.

const field = document.querySelector('#email');

// First check happens only when they leave the field
field.addEventListener('blur', () => {
  field.dataset.touched = 'true';
  validateEmail(field);
});

// After that, correct in real time as they retype
field.addEventListener('input', () => {
  if (field.dataset.touched) validateEmail(field);
});

function validateEmail(input) {
  const ok = input.validity.valid;   // let the type="email" do the work
  input.setAttribute('aria-invalid', String(!ok));
  const msg = input.nextElementSibling; // an .error-message element
  msg.textContent = ok ? '' : 'Enter a valid email like name@example.com';
}

Cross-field validation

Some rules span two fields β€” password confirmation, a date range where "end" must be after "start", a shipping address required only when "same as billing" is unchecked. The trick is to re-run the comparison whenever either field changes, as the password example above showed. Always validate the dependent field again on submit, too.

πŸ’‘ Show success, not just failure

A green check on a correctly filled field is reassuring and reduces hesitation. Toggle a valid class alongside your invalid one so users can see they're on track β€” validation isn't only about scolding.

Asynchronous Validation

Some questions only the server can answer: is this username taken? does this promo code exist? These require an async request β€” and two safeguards: debouncing so you don't hammer the API on every keystroke, and a loading indicator so the user knows something is happening.

sequenceDiagram participant User participant Browser participant Server User->>Browser: Types a username Browser->>Browser: Debounce (wait 400ms of quiet) Browser->>User: Show "checking…" spinner Browser->>Server: GET /api/check-username?u=raydev Server->>Browser: { available: false } Browser->>User: "That username is taken"
const username = document.querySelector('#username');

// Debounce: only run after the user stops typing for `delay` ms
function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

const checkUsername = debounce(async () => {
  const value = username.value.trim();
  if (value.length < 3) return;

  username.setAttribute('aria-busy', 'true');
  try {
    const res = await fetch(
      `/api/check-username?u=${encodeURIComponent(value)}`
    );
    const { available } = await res.json();
    username.setCustomValidity(available ? '' : 'That username is taken');
    username.nextElementSibling.textContent =
      available ? 'βœ… Available' : '❌ Already taken';
  } catch (err) {
    console.error('Username check failed', err);
  } finally {
    username.removeAttribute('aria-busy');
  }
}, 400);

username.addEventListener('input', checkUsername);
Like a hotel receptionist. When you ask if a room is free, the receptionist has to phone housekeeping (the server) β€” it takes a moment. So they show you a "let me check" (the spinner) rather than leaving you guessing. Async validation works exactly the same way.

Accessible Error Messages

An error message a screen-reader user never hears is no error message at all. Accessible validation rests on three pillars: proper labels, ARIA state, and live regions.

<div class="form-field">
  <label for="email">Email</label>
  <input type="email" id="email" name="email"
         aria-required="true"
         aria-invalid="false"
         aria-describedby="email-error">
  <p id="email-error" class="error-message"
     role="alert" aria-live="assertive"></p>
</div>
  • aria-describedby ties the input to its error text, so the message is read when the field gains focus.
  • aria-invalid is toggled to "true"/"false" as validity changes β€” announcing the state.
  • role="alert" with aria-live makes new error text announced the instant it appears.

βœ… Writing good error messages

Be specific and helpful, not terse and blaming. Compare:

❌ Unhelpfulβœ… Helpful
"Invalid input""Enter your email like name@example.com"
"Password bad""Use at least 8 characters, including a number and an uppercase letter"
"Error XYZ""We couldn't verify that card. Please check the number and try again"

One more rule: never signal an error with colour alone. Pair the red border with an icon and text so colour-blind users get the message too. And after a failed submit, move focus to the first invalid field with field.focus() so keyboard users land right where they need to be.

Hands-on Exercise

πŸ‹οΈ A validated sign-up form

Objective: Build a registration form that validates in real time and blocks an invalid submit.

Requirements:

  1. Fields: username (3–15 chars, letters/numbers), email, password (min 8 chars), and confirm password.
  2. Add novalidate to the form and drive everything from the Constraint Validation API plus a match check.
  3. Validate each field on blur, then re-validate on input after it's been touched.
  4. Set aria-invalid and write the message into an aria-live error element beside each field.
  5. On submit, if form.checkValidity() is false, prevent it and focus the first invalid field.
πŸ’‘ Hint

Wrap the per-field logic in one validateField(input) that reads input.validity, handles the password-match special case with setCustomValidity(), and updates ARIA + message text. Loop over all fields on submit and call form.querySelector('[aria-invalid="true"]')?.focus().

βœ… Sample solution (core logic)
const form = document.querySelector('#signup');
const password = document.querySelector('#password');
const confirm = document.querySelector('#confirm');

function validateField(input) {
  // Cross-field rule for the confirm box
  if (input === confirm) {
    input.setCustomValidity(
      confirm.value === password.value ? '' : 'Passwords do not match'
    );
  }
  const ok = input.validity.valid;
  input.setAttribute('aria-invalid', String(!ok));
  const msg = input.parentElement.querySelector('.error-message');
  msg.textContent = ok ? '' : input.validationMessage;
  return ok;
}

form.querySelectorAll('input').forEach((input) => {
  input.addEventListener('blur', () => {
    input.dataset.touched = 'true';
    validateField(input);
  });
  input.addEventListener('input', () => {
    if (input.dataset.touched) validateField(input);
  });
});

form.addEventListener('submit', (event) => {
  let allValid = true;
  form.querySelectorAll('input').forEach((input) => {
    if (!validateField(input)) allValid = false;
  });
  if (!allValid) {
    event.preventDefault();
    form.querySelector('[aria-invalid="true"]')?.focus();
  }
});

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Client-side validation is for UX; server-side is for security. You need both β€” the server always re-validates.
  • Start with free HTML5 attributes; add novalidate to keep the validity model but style errors yourself.
  • The Constraint Validation API (validity, setCustomValidity(), checkValidity()) unifies built-in and custom rules β€” remember to clear custom errors with ''.
  • Validate on blur, then live on input; debounce async checks and show a busy state.
  • Make errors accessible with aria-invalid, aria-describedby, and an aria-live region β€” never colour alone.

🎯 Quick Quiz

Question 1: Why can you never rely on client-side validation alone for security?

Question 2: After marking a field invalid with setCustomValidity('Taken'), how do you make it valid again?

Question 3: Which technique stops an async username check from firing a request on every single keystroke?

πŸ“š Further Reading

πŸš€ What's Next?

With valid data in hand, the next step is packaging and sending it. Next we'll dive deep into the FormData API β€” building objects from forms, handling file uploads and multi-value fields, and processing the result on Node, PHP, and Python backends.