Lesson 19 of 30

Form Validation

Why Validate in the Browser at All

Form validation in the browser exists to help the user. It gives immediate feedback, saves a round trip to the server, and catches obvious mistakes before they turn into a failed submission or a support ticket.

It provides no security whatsoever. Anyone can disable JavaScript, edit your page in the developer tools, or send the request directly with a command-line tool, and your validation is simply not there. The rule is absolute and has no exceptions: every check you do in the browser must be repeated on the server. Browser validation is convenience. Server validation is correctness.

Knowing that changes what you optimise for. Client-side checks should be fast, friendly and forgiving — say what is wrong in plain language, next to the field it concerns. They do not need to be exhaustive, because the server is the authority. Trying to make them exhaustive is how people end up with a 200-character regular expression for email addresses that confidently rejects addresses which are perfectly valid.

One more framing point. The goal is a form that is easy to complete correctly, not one that is hard to complete incorrectly. Accept a phone number with spaces in it and strip them yourself. Trim whitespace instead of rejecting it. Every rule you enforce should be one you genuinely need, because each one is another way for a real user to be turned away.

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

form.addEventListener('submit', function (event) {
  event.preventDefault();          // always the first line

  const email = form.elements.email.value.trim();
  if (!email.includes('@')) {
    console.log('Please enter an email address');
    return;
  }

  console.log('Client-side checks passed - now send it');
  // And the server must check every one of these again.
});
Notes
  • If you remember one sentence from this lesson, make it this one: client-side validation is a user-experience feature, not a security control. Treating it as security is how systems end up trusting data that never went near your rules.

Reading Form Values Correctly

Every field's value is a string. That includes type="number", type="date" and type="range" — the browser restricts what can be typed, but what you read back is still text. Convert at the moment you read it, once, and every check after that is working on the right type.

Checkboxes and radio buttons do not keep their state in value. A checkbox's state is checked, a boolean. A group of radio buttons shares one name, and the tidy way to read the chosen one is form.querySelector('input[name="plan"]:checked') — which returns null when nothing has been selected, so check before reading .value off it.

A form element gives you three convenient ways in. form.elements is a collection keyed by each field's name attribute, so form.elements.email is the email field with no selector needed. new FormData(form) collects every named field into an iterable object, which is the quickest way to gather a whole form at once and is also what you will send with fetch in Lesson 23. And querySelector works as it always does.

Trim as you read. Calling .trim() at the point of reading means an entry of three spaces is correctly treated as empty, and a pasted email with a trailing space does not fail later for a reason the user cannot see on their screen.

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

// Everything is a string, including number fields
const ageText = form.elements.age.value;      // '21'
const age = Number(ageText);                  // 21
console.log(typeof ageText, typeof age);      // 'string' 'number'

// Trim as you read
const email = form.elements.email.value.trim();

// Checkboxes use checked, not value
const agreed = form.elements.terms.checked;   // true or false

// Radios: read the checked one, and handle "none selected"
const chosen = form.querySelector('input[name="plan"]:checked');
console.log(chosen ? chosen.value : 'nothing selected');

// FormData gathers every named field in one step
const data = Object.fromEntries(new FormData(form));
console.log(data);   // { email: '...', age: '21', plan: 'monthly' }
Notes
  • Object.fromEntries(new FormData(form)) keeps only the last value when several fields share a name, which is fine for ordinary forms and wrong for checkbox groups. For those, use formData.getAll('name'), which returns an array.

Built-in Validation and the Constraint API

The browser already validates a great deal for free, and using it means less code and better accessibility. required, type="email", minlength, maxlength, min, max and pattern are all attributes you write in the HTML, and the browser enforces them with no JavaScript at all.

With those in place the browser refuses to submit an invalid form and shows its own message, and you get keyboard and screen-reader support without writing a line of it. The catch is that the default messages are generic, cannot be styled, and appear one at a time — which is why most real projects add novalidate to the form to switch off the automatic behaviour, then use the same constraints deliberately through JavaScript.

That is what the Constraint Validation API is for. Every field has checkValidity(), which returns a boolean, and a validity object of specific flags: valueMissing, typeMismatch, tooShort, rangeUnderflow, patternMismatch and several more. Those flags let you write a precise message for each specific failure while the browser does the actual checking, which is both less code and more accurate than reimplementing the rules.

setCustomValidity('...') marks a field invalid with your own message, and calling it with an empty string clears the mark again. That is how you express a rule the attributes cannot — "the two passwords must match", "this username is already taken" — while keeping everything inside one validation system rather than two.

Example
// In the HTML:
//   <form id="signup" novalidate>
//     <input name="email" type="email" required>
//     <input name="age" type="number" min="13" max="120" required>
//     <input name="password" minlength="8" required>
//   </form>

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

console.log(email.checkValidity());        // false when empty or malformed
console.log(email.validity.valueMissing);  // true when it is empty
console.log(email.validity.typeMismatch);  // true when it is not an email

// Your own wording for each specific failure
function messageFor(field) {
  if (field.validity.valueMissing) return 'This field is required';
  if (field.validity.typeMismatch) return 'Enter a valid email address';
  if (field.validity.tooShort) return `At least ${field.minLength} characters`;
  if (field.validity.rangeUnderflow) return `Minimum is ${field.min}`;
  return field.validationMessage;   // the browser's own text as a fallback
}

// A rule the attributes cannot express
const pw = form.elements.password;
const confirm = form.elements.confirmPassword;
confirm.setCustomValidity(
  pw.value === confirm.value ? '' : 'Passwords do not match'
);
Notes
  • A field carrying a custom validity message stays invalid until you clear it with setCustomValidity(''). Forgetting to clear it is the classic cause of a form that refuses to submit even after the user has fixed the problem.

Writing Your Own Checks

Beyond the built-ins you will always write a handful of rules yourself. Keep each one as a small function that takes a value and returns either an error message or null. Rules written that way are easy to read, easy to test on their own, and easy to reorder or reuse on another form.

Email is where people most often go wrong. The full specification for a valid address is genuinely complicated, and almost every short regular expression circulating online rejects addresses that are perfectly legal. The browser's type="email" check, or a simple test that there is one @ with something on each side and a dot in the domain, is enough. Real verification means sending an email and having the user click a link — nothing you can do in a form proves an address actually works.

Indian mobile numbers are a common requirement and a good example of being forgiving. Strip spaces, hyphens and a leading +91 or 0 first, then check that what remains is ten digits beginning with 6, 7, 8 or 9. Rejecting 98765 43210 because of one space is an entirely self-inflicted problem, and the user has no way of knowing what you objected to.

For everything else, prefer explicit comparisons over clever patterns. A length check is value.length >= 8. An age range is two comparisons. A password rule is a list of conditions you can report individually — "needs a number", "needs a capital letter" — which is far more useful than one message announcing that the password is not strong enough.

Example
// Each rule returns an error message, or null when the value passes
function required(value) {
  return value.trim() === '' ? 'This field is required' : null;
}

function minLength(value, n) {
  return value.length < n ? `Must be at least ${n} characters` : null;
}

function emailRule(value) {
  const parts = value.trim().split('@');
  if (parts.length !== 2) return 'Enter a valid email address';
  if (!parts[0] || !parts[1].includes('.')) return 'Enter a valid email address';
  return null;
}

function indianMobile(value) {
  const digits = value.replace(/[\s-]/g, '').replace(/^(\+91|0)/, '');
  return /^[6-9]\d{9}$/.test(digits) ? null : 'Enter a 10-digit mobile number';
}

console.log(required('   '));                   // 'This field is required'
console.log(minLength('abc', 8));               // 'Must be at least 8 characters'
console.log(emailRule('ananya@example.com'));   // null
console.log(indianMobile('+91 98765-43210'));   // null
console.log(indianMobile('12345'));             // 'Enter a 10-digit mobile number'
Notes
  • Rules that return null for success and a string for failure compose neatly: run them in order and take the first non-null result. That is much easier to read than a chain of booleans plus a separate list of messages that has to be kept in step.

Showing Errors Well

Where and when you show an error matters as much as the check behind it. Three rules cover most of the ground.

Put the message next to the field, not in an alert box at the top of the page and never in a browser dialogue. A user who has to scroll up to learn what is wrong and then scroll back down to fix it is a user who abandons the form. On a long form, a summary at the top is worth adding as well — but make each entry a link that moves focus to its field.

Say what to do, not what went wrong. "Enter a 10-digit mobile number" is better than "Invalid input" in every respect. Put the rule itself in the message — the minimum length, the allowed range, the expected format — so the user never has to guess what would satisfy you.

Do not rely on colour alone. Red text is invisible to some users and hard to see on a poorly calibrated screen, so the message must exist in words too. Mark the field with aria-invalid="true" and connect the message with aria-describedby so a screen reader announces it when the field is focused. The demo below does all of that, and it also moves focus to the first invalid field on a failed submit — which saves every user, not only those using assistive technology.

A form with per-field validation
HTML
<form id="signup" novalidate class="v-demo">
  <h3>Create an account</h3>

  <label for="name">Name</label>
  <input id="name" name="name" type="text" aria-describedby="name-err">
  <p class="err" id="name-err"></p>

  <label for="email">Email</label>
  <input id="email" name="email" type="email" aria-describedby="email-err">
  <p class="err" id="email-err"></p>

  <label for="mobile">Mobile</label>
  <input id="mobile" name="mobile" type="text" placeholder="10 digits" aria-describedby="mobile-err">
  <p class="err" id="mobile-err"></p>

  <label for="password">Password</label>
  <input id="password" name="password" type="password" aria-describedby="password-err">
  <p class="err" id="password-err"></p>

  <button type="submit">Sign up</button>
  <p id="result"></p>
</form>
CSS
.v-demo { padding: 20px; background: #f0f0f0; border-radius: 8px; font-family: system-ui, sans-serif; max-width: 380px; }
.v-demo label { display: block; margin-top: 12px; font-size: 0.9rem; font-weight: 600; }
.v-demo input { width: 100%; padding: 9px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box; }
.v-demo input.invalid { border-color: #c0392b; background: #fdf2f1; }
.err { color: #c0392b; font-size: 0.82rem; margin: 4px 0 0; min-height: 1em; }
.v-demo button { margin-top: 16px; padding: 10px 18px; background: #d1039e; color: white; border: none; border-radius: 5px; cursor: pointer; }
#result { margin-top: 12px; font-size: 0.9rem; color: #222; }
JavaScript
const form = document.getElementById('signup');
const result = document.getElementById('result');

// One rule per field. Each returns an error string, or null when valid.
const rules = {
  name: function (v) {
    return v.trim() === '' ? 'Please enter your name' : null;
  },
  email: function (v) {
    const parts = v.trim().split('@');
    if (parts.length !== 2 || !parts[0] || !parts[1].includes('.')) {
      return 'Enter a valid email address';
    }
    return null;
  },
  mobile: function (v) {
    const digits = v.replace(/[\s-]/g, '').replace(/^(\+91|0)/, '');
    return /^[6-9]\d{9}$/.test(digits) ? null : 'Enter a 10-digit mobile number';
  },
  password: function (v) {
    return v.length < 8 ? 'At least 8 characters' : null;
  }
};

function showError(name, message) {
  const field = form.elements[name];
  document.getElementById(name + '-err').textContent = message || '';
  field.setAttribute('aria-invalid', message ? 'true' : 'false');
  field.classList.toggle('invalid', Boolean(message));
}

function validateField(name) {
  const message = rules[name](form.elements[name].value);
  showError(name, message);
  return message === null;
}

// Check when the user leaves a field, not while they are still typing
Object.keys(rules).forEach(function (name) {
  const field = form.elements[name];
  field.addEventListener('blur', function () { validateField(name); });

  // Once it is showing an error, update live so they see it clear
  field.addEventListener('input', function () {
    if (field.classList.contains('invalid')) validateField(name);
  });
});

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

  let firstBad = null;
  Object.keys(rules).forEach(function (name) {
    if (!validateField(name) && firstBad === null) firstBad = name;
  });

  if (firstBad) {
    form.elements[firstBad].focus();
    result.textContent = 'Please fix the highlighted fields.';
    return;
  }

  result.textContent = 'All checks passed. The server would now verify it too.';
});
Notes
  • Try submitting the empty form first. Focus jumps to the Name field, every message appears at once, and each one tells you what to do rather than what you did wrong. Then fix one field and watch its message clear as you type, because the input listener only runs once that field is already showing an error.

When to Validate

Timing is the difference between a form that feels helpful and one that feels like it is nagging, and it costs nothing to get right.

Validating on every keystroke tells the user their email address is invalid after they have typed one letter, which is both useless and irritating. Validating only on submit means they discover four separate problems at once, after filling in everything. The pattern that works sits in between: check a field when the user leaves it, using the blur event, and then — once a field is already showing an error — update it live on input so they can watch the message disappear the moment they fix it.

Always revalidate everything on submit regardless, because a user can reach the submit button without ever visiting a field: by pressing Enter, by using autofill, or by tabbing past. On failure, move focus to the first invalid field so the user is taken to the problem rather than left to hunt for it.

Two more things belong in every submit handler. Disable the submit button while a request is in flight, so an impatient double-click cannot create two records. And re-enable it in every path, including the failure path — a button left permanently disabled after an error is a bug your users will report as "the site is stuck", and it is one of the most common defects in hand-written forms.

  • Browser validation is for the user; the server must repeat every single check
  • event.preventDefault() is the first line of every submit handler
  • Every value is a string, including number and date fields
  • Checkboxes use checked; radio groups need a :checked query
  • Validate on blur, then live on input once an error is showing
  • Always revalidate everything on submit, and focus the first invalid field
  • Messages next to the field, in words, saying what to do rather than what failed
  • Be forgiving: trim spaces, strip formatting, never reject a valid number over a hyphen
Notes
  • Test your own form the way a real user meets it: submit it completely empty, paste a value with a trailing space, and press Enter from inside a text field instead of clicking the button. Those three attempts find most validation bugs before anyone else does.
Ask AI