Client-Side Validation

Use built-in HTML validation attributes (required, pattern, and others) together with the Constraint Validation API (the validity object, setCustomValidity, reportValidity), write accessible error messages, and understand why server-side validation remains mandatory regardless.

Modern browsers can validate a great deal of form input without any JavaScript at all, just from HTML attributes. JavaScript's role is to layer on custom rules those attributes cannot express, read exactly what went wrong through the Constraint Validation API, and present errors in a way that works for every user, including those using assistive technology.

Declarative validation: required, pattern, and friends

The required attribute marks a field as mandatory; the browser blocks submission and shows a built-in message if it is left empty. The pattern attribute takes a regular expression (without slashes) that the field's value must fully match — for example pattern="[0-9]{5}" for a five-digit zip code.

Type-specific attributes add more automatic checks: type="email" requires a roughly email-shaped value, type="number" with min and max constrains the numeric range, and maxlength/minlength constrain text length. All of this validation runs automatically when the form is submitted, with zero JavaScript required to trigger it.

• required — field must have a non-empty value • pattern="regex" — value must match the given regular expression • type="email" / type="url" / type="number" — built-in format checks • minlength / maxlength — text length bounds • min / max / step — numeric bounds and increments

The Constraint Validation API: the validity object

Every form control exposes a validity object with a set of boolean properties describing exactly why (if at all) it currently fails validation: valueMissing (a required field is empty), patternMismatch (fails the pattern attribute), typeMismatch (doesn't match the input's type, like a malformed email), tooShort/tooLong, rangeUnderflow/rangeOverflow, and the overall valid flag.

checkValidity() runs the checks and returns true or false without showing any UI, while reportValidity() runs the same checks and, if invalid, also shows the browser's built-in validation bubble pointing at the offending field — this is what the browser calls automatically on form submission.

Custom validation with setCustomValidity

Some rules can't be expressed with pattern or the other attributes alone — the classic example is "password and confirm-password must match," which depends on comparing two different fields. setCustomValidity(message) lets you mark any field as invalid with your own message: calling it with a non-empty string makes the field invalid (and shows that message via reportValidity/native submission); calling it with an empty string "" clears the custom error and lets the field be valid again.

It is essential to re-run this check (and clear it when appropriate) on every relevant input event, not just once — otherwise a field that was marked invalid can get permanently stuck invalid even after the user fixes it.

Accessible error messages

A red border alone is not enough — screen reader users need the error text to be announced and clearly associated with its field. Connect a visible error message to its input using aria-describedby, pointing at the id of the element containing the error text, so assistive technology reads the error when the field receives focus.

Marking the error container with aria-live="polite" causes screen readers to announce new error text as soon as it appears, even if focus hasn't moved — useful for errors that show up asynchronously (like a username-already-taken check). Also set aria-invalid="true" on the input itself while it has an active error, and remove it once the error clears.

Why server-side validation is still required

Client-side validation exists purely to give the user fast, friendly feedback before a request is even sent — it never protects your actual data. Anyone can bypass browser-side checks entirely: disable JavaScript, edit the HTML in devtools, or simply send a raw HTTP request straight to your server's endpoint with a tool like curl, skipping the browser (and its validation) altogether.

Because of that, every rule enforced on the client — required fields, formats, length limits, permissions — must be enforced again on the server, which is the only place that can be trusted. The correct mental model is: client-side validation is for user experience; server-side validation is for security and data integrity, and it is never optional.

Reading and setting validity

validity is read-only — it reflects the current state, computed from the field's attributes and value. setCustomValidity("") is required to clear a custom error; forgetting it leaves the field permanently invalid. checkValidity() / reportValidity() re-run all checks, including any custom validity currently set.

const input = document.querySelector("#username");

if (!input.validity.valid) {
  console.log(input.validity.valueMissing);   // true if empty and required
  console.log(input.validity.patternMismatch); // true if it fails pattern
}

input.setCustomValidity("Username is already taken");
// ... and to clear it again once fixed:
input.setCustomValidity("");

Code Examples

The pattern and required attributes do the actual matching automatically; JavaScript here just reads the resulting validity flags to decide which message to show, without writing any regex logic itself. Expected result: (typing "12a") Zip code must be exactly 5 digits (typing "12345") Zip code looks valid zip.validity.patternMismatch is true whenever the current value fails the pattern attribute's regular expression.

This is the canonical setCustomValidity use case: a rule (matching two fields) that pattern alone cannot express. Both fields are watched so the check re-runs whether the user edits the password or the confirmation, and the custom error is explicitly cleared with "" the moment they match again. Expected result: (mismatched) confirm-password becomes invalid, blocking submission with "Passwords do not match" (matched) confirm-password becomes valid again confirm.setCustomValidity("") on the matching branch is what prevents the field from staying stuck invalid after the user fixes it.

This wires the browser's own validity checks to a custom, accessible error message instead of relying on the browser's default validation bubble. #email-error should be marked aria-live="polite" in the HTML, and #email should have aria-describedby="email-error", so screen readers announce the message and associate it with the field. Expected result: (invalid email format) email-error reads: "Please enter a valid email address.", email gets aria-invalid="true" (fixed) email-error is cleared, aria-invalid is removed aria-invalid is toggled to match the field's real validity state, which screen readers use to announce the field as erroneous.

// HTML: <input id="zip" pattern="[0-9]{5}" required>
const zip = document.querySelector("#zip");

zip.addEventListener("input", function () {
  if (zip.validity.patternMismatch) {
    console.log("Zip code must be exactly 5 digits");
  } else if (zip.validity.valueMissing) {
    console.log("Zip code is required");
  } else {
    console.log("Zip code looks valid");
  }
});
const password = document.querySelector("#password");
const confirm = document.querySelector("#confirm-password");

function checkPasswordsMatch() {
  if (confirm.value !== password.value) {
    confirm.setCustomValidity("Passwords do not match");
  } else {
    confirm.setCustomValidity("");
  }
}

password.addEventListener("input", checkPasswordsMatch);
confirm.addEventListener("input", checkPasswordsMatch);
const form = document.querySelector("#signup-form");
const email = document.querySelector("#email");
const emailError = document.querySelector("#email-error");

email.addEventListener("input", function () {
  if (email.validity.valid) {
    emailError.textContent = "";
    email.removeAttribute("aria-invalid");
  } else if (email.validity.typeMismatch) {
    emailError.textContent = "Please enter a valid email address.";
    email.setAttribute("aria-invalid", "true");
  } else if (email.validity.valueMissing) {
    emailError.textContent = "Email is required.";
    email.setAttribute("aria-invalid", "true");
  }
});

Important Rules

HTML validation attributes (required, pattern, type, min/max, minlength/maxlength) run automatically with no JavaScript.

input.validity exposes why a field is currently invalid (valueMissing, patternMismatch, typeMismatch, and more).

setCustomValidity(message) marks a field invalid with a custom rule; setCustomValidity("") is required to clear it again.

Associate error text with its field using aria-describedby, and announce dynamic errors with aria-live.

Client-side validation is a UX convenience only — every rule must be re-enforced on the server, since client-side checks can always be bypassed.

Where this shows up

Every signup and checkout form on the web layers these techniques together: HTML attributes catch the easy cases instantly with zero JavaScript, setCustomValidity handles cross-field and business-specific rules, aria-describedby/aria-live make the result usable with a screen reader, and — invisibly, on the back end — the exact same rules are re-checked in the server-side API before anything is saved to a database.

if (password.value.length < 8) { input.setCustomValidity("Too short"); } // missing an else branch to clear it if (password.value.length < 8) { input.setCustomValidity("Too short"); } else { input.setCustomValidity(""); }

Without the else branch clearing the custom validity, once a field has been marked invalid it stays invalid forever, even after the user fixes the length — because setCustomValidity("") is the only way to remove a previously set custom error.

// Relying only on client-side required/pattern checks and skipping validation on the server

// Re-validate every field again in the server-side request handler before trusting or storing the data

Any client-side check, no matter how thorough, can be bypassed by disabling JavaScript or sending a request directly to the server. Skipping server-side validation means the server will trust and store whatever raw data arrives, regardless of what the browser would have blocked.

[Multiple Choice] What is required after calling input.setCustomValidity("some error") once the underlying problem is fixed? A. Nothing — it clears itself automatically on the next input event B. Calling input.setCustomValidity("") explicitly to clear the custom error C. Reloading the page D. Calling input.reset()

B. Calling input.setCustomValidity("") explicitly to clear the custom error — A custom validity message set with setCustomValidity persists until it is explicitly cleared by calling setCustomValidity("") with an empty string — it does not clear itself automatically.

[True / False] Because a form has thorough client-side (JavaScript and HTML attribute) validation, the server does not need to validate that same data again. A. True B. False

B. False — False. Client-side validation can always be bypassed (disabled JavaScript, edited HTML, or a direct HTTP request to the server), so the server must independently re-validate every rule before trusting or storing the data.

Given an <input id="age" type="number" min="18" max="120" required> and an empty <p id="age-error" aria-live="polite">, write an input listener that shows "Age is required." for a missing value, "You must be at least 18." for rangeUnderflow, "Please enter a realistic age." for rangeOverflow, and clears the message when valid.

const age = document.querySelector("#age"); const ageError = document.querySelector("#age-error"); age.addEventListener("input", function () { if (age.validity.valueMissing) { ageError.textContent = "Age is required."; } else if (age.validity.rangeUnderflow) { ageError.textContent = "You must be at least 18."; } else if (age.validity.rangeOverflow) { ageError.textContent = "Please enter a realistic age."; } else { ageError.textContent = ""; } });

Check input.validity.valueMissing, then input.validity.rangeUnderflow, then input.validity.rangeOverflow, in that order.