π‘οΈ 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.
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 types | The final, authoritative check |
| Improves the experience, cuts server load | Cannot be bypassed by the user |
| Can be disabled or tampered with | Handles checks needing the database (e.g. "is this email taken?") |
| Fast β no network round-trip | Slower β 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.
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-describedbyties the input to its error text, so the message is read when the field gains focus.aria-invalidis toggled to"true"/"false"as validity changes β announcing the state.role="alert"witharia-livemakes 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:
- Fields: username (3β15 chars, letters/numbers), email, password (min 8 chars), and confirm password.
- Add
novalidateto the form and drive everything from the Constraint Validation API plus a match check. - Validate each field on
blur, then re-validate oninputafter it's been touched. - Set
aria-invalidand write the message into anaria-liveerror element beside each field. - 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
novalidateto 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 anaria-liveregion β 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
- MDN β Client-side form validation
- MDN β The Constraint Validation API
- W3C WAI β Validating input accessibly
π 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.