Skip to content
Happy Programming Guide
Start learning
JavaScript

JavaScript Form Validation Explained

Use the browser’s built-in validation first, then add JavaScript for the parts it cannot do — with error messages screen readers actually announce.

A close-up of programming code on a screen

Most form validation should start in HTML, not JavaScript. The browser already checks required fields, email format and length, and it does it consistently and accessibly.

HTML
<input type="email" required minlength="5">

JavaScript is for the parts HTML cannot express: comparing two fields, checking something against a server, and writing error messages in your own words.

Start with HTML#

HTML
<form id="signup" novalidate>
  <div class="field">
    <label for="email">Email address</label>
    <input id="email" name="email" type="email" required autocomplete="email"
           aria-describedby="email-error">
    <p class="error" id="email-error"></p>
  </div>

  <div class="field">
    <label for="password">Password</label>
    <input id="password" name="password" type="password" required minlength="8"
           autocomplete="new-password" aria-describedby="password-error">
    <p class="error" id="password-error"></p>
  </div>

  <div class="field">
    <label for="confirm">Confirm password</label>
    <input id="confirm" name="confirm" type="password" required
           autocomplete="new-password" aria-describedby="confirm-error">
    <p class="error" id="confirm-error"></p>
  </div>

  <button type="submit">Create account</button>
</form>

Three things doing real work here:

  • The attributes — required, type="email", minlength — are the validation rules. The browser enforces them.
  • novalidate switches off the browser’s own pop-up bubbles while keeping the rules. You still read the results through JavaScript; you just draw the messages yourself.
  • aria-describedby links each input to its error paragraph, so a screen reader reads the error as part of the field.

Getting type right also brings up the correct keyboard on a phone, which is a real usability win for free. See HTML basics explained.

What the browser gives you#

Every input has a validity object describing exactly what is wrong:

JavaScript
const email = document.querySelector("#email");

email.validity.valid;          // true / false overall
email.validity.valueMissing;   // required, but empty
email.validity.typeMismatch;   // not a valid email
email.validity.tooShort;       // under minlength
email.validity.patternMismatch;
email.validity.rangeOverflow;  // over max on a number

email.checkValidity();         // same as .validity.valid

That means you never have to write an email regular expression. Ask the browser.

Writing your own messages#

Default messages are generic. Map each failure to something specific:

JavaScript
function messageFor(input) {
  const v = input.validity;
  const label = input.labels[0]?.textContent ?? "This field";

  if (v.valueMissing) return `${label} is required.`;
  if (v.typeMismatch && input.type === "email") return "Enter an email address, like you@example.com";
  if (v.tooShort) return `Use at least ${input.minLength} characters.`;
  if (v.patternMismatch) return input.dataset.hint ?? "That format is not accepted.";
  if (v.rangeOverflow) return `Choose ${input.max} or lower.`;
  if (v.rangeUnderflow) return `Choose ${input.min} or higher.`;
  return input.validationMessage;
}

Say what to do, not just what is wrong. “Enter an email address, like [email protected]” beats “Invalid input”.

Showing and clearing the error#

JavaScript
function showError(input, message) {
  const target = document.querySelector(`#${input.id}-error`);
  target.textContent = message;
  input.setAttribute("aria-invalid", "true");
  input.classList.add("is-invalid");
}

function clearError(input) {
  const target = document.querySelector(`#${input.id}-error`);
  target.textContent = "";
  input.removeAttribute("aria-invalid");
  input.classList.remove("is-invalid");
}

aria-invalid is what tells assistive technology the field is in an error state. Colour alone does not communicate that to everyone.

Cross-field rules#

HTML cannot say “these two must match”. This is exactly what setCustomValidity is for — it makes your own rule part of the browser’s validity system:

JavaScript
const password = document.querySelector("#password");
const confirm = document.querySelector("#confirm");

function checkMatch() {
  if (confirm.value && confirm.value !== password.value) {
    confirm.setCustomValidity("Passwords do not match.");
  } else {
    confirm.setCustomValidity("");     // empty string = valid
  }
}

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

You must clear it with an empty string when the problem is resolved. Forgetting that leaves the field permanently invalid, which is a genuinely confusing bug to track down.

Validating at the right moment#

Timing matters more than the rules. Validating on every keystroke tells someone their email is invalid after they have typed one letter, which is hostile.

The pattern that feels right:

  • Do not validate while they are typing for the first time
  • Validate on blur — when they leave the field
  • Then re-validate on input, so a corrected field clears its error immediately
  • Validate everything on submit and focus the first problem
JavaScript
const form = document.querySelector("#signup");
const fields = [...form.querySelectorAll("input")];

fields.forEach((input) => {
  input.addEventListener("blur", () => {
    input.dataset.touched = "true";
    validate(input);
  });

  input.addEventListener("input", () => {
    if (input.dataset.touched) validate(input);   // only after first blur
  });
});

function validate(input) {
  if (input.checkValidity()) {
    clearError(input);
    return true;
  }
  showError(input, messageFor(input));
  return false;
}

Submitting#

JavaScript
form.addEventListener("submit", (event) => {
  event.preventDefault();

  checkMatch();
  fields.forEach((f) => (f.dataset.touched = "true"));

  const problems = fields.filter((f) => !validate(f));

  if (problems.length) {
    problems[0].focus();      // send them straight to the first one
    return;
  }

  const data = Object.fromEntries(new FormData(form));
  console.log("Ready to send:", data);
});

Object.fromEntries(new FormData(form)) collects every named field into a plain object in one line — much better than reading each input by id.

Focusing the first invalid field matters. On a long form, an error message above the fold that nobody scrolls to is the same as no message.

The CSS#

CSS
.field { margin-bottom: 18px; }
.field label { display: block; margin-bottom: 6px; font-weight: 600; }

.field input {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid #ccc;
  border-radius: 8px;
  font: inherit;
}

.field input:focus-visible { outline: 2px solid #4f46e5; outline-offset: 1px; }
.field input[aria-invalid="true"] { border-color: #dc2626; }

.error { min-height: 1.2em; margin: 6px 0 0; color: #b91c1c; font-size: .85rem; }

Styling on [aria-invalid="true"] rather than a class means the visual state and the accessible state can never disagree. The min-height stops the layout jumping.

Questions people ask#

Should I use a validation library?

Not for a form this size. The browser does most of it, and understanding the Constraint Validation API means you can read what a library is doing when you do reach for one.

What is the difference between checkValidity and reportValidity?

checkValidity() returns true or false silently. reportValidity() does the same and also shows the browser’s own message bubbles — useful if you are happy with the defaults and want to skip the custom messages entirely.

How do I validate a checkbox group?

The Constraint Validation API has no rule for “at least one of these”. Count the checked boxes yourself and call setCustomValidity on the first one in the group.

Can I style the browser’s default bubbles?

Not meaningfully, which is the main reason for novalidate and drawing your own.

What about async checks like “is this username taken”?

Do that on blur with fetch, show a pending state while it runs, and always re-check on the server at submit. Never let a slow answer arrive after the form has been sent.

Where to go next#

Related lessonJavaScript events explained

Keep reading

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *