Form validation
Validate browser form fields with required, type, pattern, validity, setCustomValidity, custom messages, and accessible error UI while still trusting the server for final checks.
- 01Use the built-in HTML rulesAdd rules to a field with
required, the inputtype,pattern,min,max,step,minlength, andmaxlength. - 02Read and change the checklistRead the flags in
validity, tellcheckValidity()andreportValidity()apart, and add or remove your own error withsetCustomValidity(). - 03Write errors everyone can useShow your own messages, a summary, and a focus move, linked to each field with
aria-invalidandaria-describedby.
What form validation does
Form validation means checking what a person typed into a form before you accept it. Each check is a rule. For example:
- A signup form needs an email address.
- A booking form needs a date after today.
- A coupon box takes only a code shaped like
SAVE20.
The browser can run many of these checks for you. Its tools for this are called the Constraint Validation API. A constraint is one rule for one field. You write the rules as HTML attributes. Then JavaScript can:
- read
input.validityto see which rules fail, - call
checkValidity()to test a field or a whole form, - call
setCustomValidity()to add a rule of your own.
function passwordMessage(password, confirm) { return password === confirm ? "" : "Passwords must match";} console.log(passwordMessage("otter", "owl"));console.log(passwordMessage("otter", "otter"));The function compares two passwords. Line 2 gives back an empty string, "", when they match. When they don't match, it gives back the message Passwords must match. Here the empty string means "no error".
So line 5 prints Passwords must match, and line 6 prints an empty line. Later in this lesson, you'll pass this same result to confirm.setCustomValidity().
A bouncer is the person at a club door who checks each guest. The bouncer goes down a checklist. Does the guest have a ticket? Is it tonight's ticket? Is the guest old enough? Each rule gets its own tick or cross.
The browser does the same for a form field. It keeps one flag for each rule. So you can see exactly which rule failed.
- In real life: The guest must have a ticket
- In JavaScript:
required: the field can't be empty - In real life: The ticket must look like tonight's ticket
- In JavaScript:
patternand the inputtype: the value must have the right shape - In real life: The guest must be old enough
- In JavaScript:
min,max, andstep: a number or date must be in the allowed range - In real life: A tick or a cross for each rule
- In JavaScript:
validity: one true-or-false flag for each rule, such asvalueMissing
A receptionist helps visitors fill in their forms correctly. But the receptionist doesn't keep the building safe. Security does that.
The browser check is the receptionist. It helps people fix small mistakes while the form is still on the screen. The server is the computer that receives and saves the form data. It is the security guard. The browser check can't prove the data is safe, because anyone can send data to your server without using your page.
- In real life: The receptionist points out a missing signature
- In JavaScript: The browser points out simple mistakes right away
- In real life: The manager adds a rule just for today
- In JavaScript:
setCustomValidity('Passwords must match')adds your own rule - In real life: Security checks the ID again later
- In JavaScript: The server checks the important rules again
required, pattern, and input types
STEP THROUGHStart with HTML. The browser already knows many common rules. These rules also help other tools understand the field, such as phone keyboards, password managers, and screen readers.
Each field has a validity object. It holds one flag for each rule. A flag is a true-or-false value, and it is true when its rule fails. Here are the main rules and their flags:
requiredmeans the field can't be empty. An empty required field turns onvalidity.valueMissing.type="email"andtype="url"want an email address or a web address. A value in the wrong shape turns ontypeMismatch.type="number"and the date types accept only a number or a date.patterngives a shape that the whole value must match. It is skipped when the field is empty. You'll learn the pattern language in the Regular expressions lesson. For now, read[A-Z]{3}as "three capital letters".min,max, andstepset which numbers or dates are allowed.minlengthandmaxlengthset how long the text may be. Browsers turn ontooShortonly after a person has typed the value.
const form = document.createElement("form");const field = document.createElement("input");field.required = true;field.type = "email";field.minLength = 5;form.append(field);document.body.append(form); console.log(field.validity.valueMissing);field.value = "asha@";console.log(field.validity.typeMismatch);field.value = "ash";field.dispatchEvent(new Event("input", { bubbles: true }));console.log(field.validity.tooShort);Lines 1 to 7 make a form with one email field. The field is required, must be an email address, and needs at least 5 characters (minLength). Then:
- Line 9 prints
trueforvalueMissing, because the field is still empty. - Line 10 puts
asha@in the field. That is not a full email address, so line 11 printstruefortypeMismatch. - Line 12 puts
ashin the field, which has only 3 characters. Even so, line 14 printsfalsefortooShort.
Why false? A value set by code doesn't count as typed, even with the made-up input event on line 13. To see tooShort turn on, type into the dashboard in the next section.
The pattern must match the whole value, from the first character to the last. So [A-Z]{3} accepts ABC but not ABCx. Current browsers read the pattern in a strict mode, the regular expression v flag. In that mode, some punctuation inside square brackets needs a backslash before it.
Some fields skip validation completely. For those fields, input.willValidate is false, and the browser never checks their rules. This happens for:
- disabled fields,
- read-only text fields,
- fields with
type="hidden".
<label> Age <input name="age" type="number" min="13" max="120" step="1" required></label>This field needs a whole number from 13 to 120. Line 3 holds all the rules. Each wrong value turns on a different flag:
- An empty field turns on
valueMissing, because ofrequired. 12turns onrangeUnderflow, which means "belowmin".121turns onrangeOverflow, which means "abovemax".13.5turns onstepMismatch, becausestep="1"allows only whole numbers.
The browser checks these rules before your submit code runs.
const pin = document.createElement("input");pin.pattern = "[0-9]{6}";pin.value = "1234567";console.log(pin.validity.patternMismatch);pin.value = "123456";console.log(pin.validity.patternMismatch); const tickets = document.createElement("input");tickets.type = "number";tickets.min = "2";tickets.max = "10";tickets.step = "2";tickets.value = "3";console.log(JSON.stringify({ underflow: tickets.validity.rangeUnderflow, step: tickets.validity.stepMismatch }));Line 2 says the PIN must be exactly six digits. Line 4 prints true for patternMismatch, because 1234567 has seven digits. The pattern must match the whole value, so six of the seven digits is not enough. Line 6 prints false, because 123456 matches.
Lines 8 to 13 make a ticket field that allows 2 to 10, in steps of 2. So the allowed values are 2, 4, 6, 8, and 10. Line 14 prints {"underflow":false,"step":true}. The value 3 is inside the range, so rangeUnderflow is false. But 3 is not one of the steps, so stepMismatch is true.
Pick a field, guess the flags, then step through the checklist.
script
const validity = check(field);console.log(validity.valueMissing);console.log(validity.patternMismatch);console.log(validity.valid);Compare the first two choices. In both, the field is empty and has a pattern. The pattern flag stays false in both, because the pattern waits for a value. Only required decides if an empty field is allowed.
pattern. The pattern waits for a value, so only required decides if an empty field is allowed.So if an empty field should be an error, add required. If the field is optional, people can leave it blank without seeing a format error.
Read the validity checklist
INTERACTIVEEvery field that takes part in validation has a validity property. Its value is a ValidityState object: a checklist with one flag for each kind of failure. It also has one last flag, valid. valid is true only when every other flag is false.
"asha@". Only the email rule fails, and that one failed rule makes valid false.The dashboard below shows the real flags from your browser. It reads them when the page loads and each time you type.
It also shows validationMessage: the error text the browser writes for that field. The browser chooses those words, so they differ between browsers and languages. For the text in your own app, write your own messages.
<form> <input name="name" required> <input name="email" type="email" required> <input name="site" type="url"> <input name="tickets" type="number" min="1" max="10" step="2"> <input name="code" minlength="3" maxlength="8" pattern="[A-Z]{3}[0-9]{2}"> <input name="day" type="date" min="2026-01-01"></form>The cards read each field's real validity object and the browser's validationMessage. They update when the page loads and each time you edit a field. The message words can differ between browsers and languages.
Try these:
- Leave Name empty:
valueMissingturns on. - Type
abcin Email:typeMismatchturns on. - Type
4in Tickets:stepMismatchturns on, because the allowed values are 1, 3, 5, 7, and 9. - Type
abin Code:tooShortturns on, because you typed it yourself.patternMismatchturns on too.
- An empty required field
- abc in
type=email - 7 in
min=0 step=5 - A password message set with
setCustomValidity
Sort each case by the flag it turns on.
- 12 typed into a text field with
minlength=3 - abcdef in a field with
maxlength=4 - ab12 in a field with
pattern="[A-Z]{2}[0-9]{2}"
Now sort these text problems by the flag they turn on.
validity and setCustomValidity
INTERACTIVEHTML attributes check one field at a time. But many real rules compare two fields, or use your app's own data. For example:
- The two passwords must match.
- The end date must come after the start date.
- A username can't be one that your app keeps for itself.
For rules like these, use setCustomValidity(message). It works like this:
- Any message that isn't empty marks that one field as invalid. It turns on
validity.customError. setCustomValidity(""), with an empty string, removes the error.- The error stays until you remove it. The empty string is the only way to turn it off.
customError on. Only the empty string "" turns it off again.confirm.addEventListener("input", () => { const match = password.value === confirm.value; confirm.setCustomValidity(match ? "" : "Passwords must match");}); form.checkValidity(); // boolean, fires invalidform.reportValidity(); // boolean, asks browser to show UIMake the two passwords match, then try the buttons.
setCustomValidity("").Three methods have similar names. Read each row as: what you call, what you get back, and what it does.
| What you call | What you get back | What it does |
|---|---|---|
input.checkValidity() or form.checkValidity() | true or false. | It checks the rules and fires an invalid event on each field that fails. It shows nothing to the person. |
input.reportValidity() or form.reportValidity() | true or false. | It checks the rules and asks the browser to show the problem to the person. |
input.setCustomValidity(message) | undefined. | It sets or removes your own error on one field. Any message that isn't empty makes the field invalid. |
const form = document.createElement("form");const email = document.createElement("input");email.type = "email";email.required = true;form.append(email);document.body.append(form);let invalidEvents = 0;form.addEventListener("invalid", () => invalidEvents++, true); console.log(form.checkValidity());console.log(form.reportValidity());console.log(invalidEvents);The email field is required and empty, so both checks fail. Line 10 prints false for checkValidity(), and line 11 prints false for reportValidity().
The invalid event is the browser's signal that a field failed a check. Line 8 counts these events, and line 12 prints 2: one event for each check. checkValidity() only tests. reportValidity() tests and also shows the browser's error bubble, a small pop-up next to the field.
const form = document.createElement("form");const email = document.createElement("input");email.required = true;form.append(email);document.body.append(form);form.addEventListener("invalid", (event) => { event.target.setCustomValidity("Email is required");}, true);form.checkValidity();console.log(email.validationMessage);Line 6 listens for invalid on the form. Line 9 checks the form, and the empty field fails. So the listener runs, and line 7 sets your own message on that field. Line 10 prints Email is required instead of the browser's words.
The invalid event does not bubble up from the field to the form. That is why line 8 passes true. With true, the listener runs in the capture phase, while the event travels down to the field. So a listener on the form still hears it.
To pick a message for a field, follow these steps:
- Read
validity.valid. - If it is
false, find the first flag that istrue. - Show your own message for that flag.
Don't use validationMessage as your app's text. The browser chooses its words and its language.
Custom error messages
INTERACTIVEThe browser's error bubbles are fine for a quick test. Real apps often want more:
- the same wording on every page,
- a list of all the errors at the top of the form,
- messages in the person's own language,
- each message placed right next to its field.
To do this, add novalidate to the form. It turns off the browser's own bubbles. Then call checkValidity() yourself and show your own messages.
A good message says how to fix the field. Use a real email address, like ada@example.com
helps more than Invalid input.
Read the same flags as before, and turn each one into a clear instruction.
<form novalidate> <input id="email" type="email" required aria-describedby="email-error"> <p id="email-error"></p> <button>Join</button></form> form.addEventListener("submit", (event) => { event.preventDefault(); if (!form.checkValidity()) showFriendlyErrors();});Submit the form to see your own messages instead of the browser's bubbles.
novalidate, so your submit code runs. It shows clear messages based on the same flags.const form = document.createElement("form");form.noValidate = true;const email = document.createElement("input");email.required = true;form.append(email);document.body.append(form);form.addEventListener("submit", (event) => { event.preventDefault(); console.log("submit -> " + form.checkValidity());});form.requestSubmit();Line 2 turns on novalidate. The email field is required and empty. Line 11 calls requestSubmit(), which submits the form the same way a submit button does.
Normally the browser would stop that submit. With novalidate, it reaches your submit listener. But the rules are still there: line 9 prints submit -> false, because checkValidity() still sees the empty field. Use novalidate only when your own code shows an equally clear error.
When should messages appear? Many teams wait for the first submit, so people don't see red errors while they are still typing. After that, they update the messages on input (each edit) or on blur (when the person leaves the field).
CSS has two selectors for this. :invalid matches a field as soon as it breaks a rule, even before the person touches it. The newer :user-invalid matches only after the person has changed the field, in browsers that support it.
Use :invalid when the state should show right away. Use :user-invalid when a field should look wrong only after the person changes it. Older browsers just skip it. Neither selector writes the message, moves focus, or replaces the server check.
Accessible errors
INTERACTIVEA red border alone doesn't help everyone. Some people use only a keyboard. Some use a screen reader, a program that reads the page aloud. Some zoom the page in a lot. They all need the error as text, and the text must be linked to its field in the HTML.
- Set
aria-invalid="true"on each field that has an error. This tells screen readers the field is invalid. - Set
aria-describedbyon the field to theidof its error text. A screen reader then reads the error together with the field. - Put a summary of all the errors near the top, with a link to each field.
- After a failed submit, move focus to the first invalid field, or to the summary. Focus is the place where typing goes.
- Make the summary a live region with
aria-live="polite". A screen reader then reads new errors aloud when it is free.
<div role="alert" aria-live="polite" id="error-summary"></div><input id="email" required aria-invalid="true" aria-describedby="email-error"><p id="email-error">Enter your email address.</p>Enter your email address.
When the error shows, aria-invalid tells screen readers the field is invalid. aria-describedby links the field to its message. The summary is a live region, so new errors are read aloud.
The form events lesson showed how to move focus and how to handle submit. Validation uses both. Catch the failed submit, show a summary, and move focus. Then keep each message linked to its field while the person fixes it.
Where you will use this
A signup form can use every tool from this lesson:
- The email field has
type="email" required. - The password field has
minlength="8". - The confirm field uses
setCustomValidity()to check that the passwords match. - On submit, your code shows its own summary of the errors.
Other forms use them too. At checkout, min, max, and step check a quantity. In settings, setCustomValidity() can block a username that your app keeps for itself.
Use the built-in HTML rules first. Add setCustomValidity() only for rules that HTML can't express. Always check again on the server. The browser check helps people; the server check protects the data.
Once a form passes, the next lesson, FormData, shows how to collect its values with new FormData(form) and get them ready to send.
function canCreateAccount(email) { const value = String(email ?? "").trim(); return value !== "" && value.includes("@");} console.log(canCreateAccount("ada@example.com"));console.log(canCreateAccount(""));The server gets plain data, with no browser rules attached. Line 2 turns whatever arrived into a string and removes the spaces at both ends. The ?? part turns null or undefined into an empty string first. Line 3 rejects an empty value and a value with no @.
So line 6 prints true, and line 7 prints false. A real server would use a full email check and its own rules before it creates the account.
canCreateAccount(), before it saves anything.Common misconceptions
- "The browser accepted it, so it is safe." Not true. The server must check every rule that matters.
- "
patternmakes a field required." No. An empty optional field skipspattern. Addrequiredfor that. - "A custom error removes itself." No. You must call
setCustomValidity("")to remove it. - "
validationMessageis good text for my app." It is the browser's text, in the browser's language. When the words matter, map each flag to your own message. - "A red border is enough." No. People need the error as text, linked to its field, plus a summary and a focus move.
Each row below is a tempting shortcut, and a better habit to use instead.
| The shortcut | Do this instead |
|---|---|
Check every rule with JavaScript if statements. | Write the rules as HTML attributes, so the browser, phone keyboards, and screen readers know about them. |
| Show every error as soon as the page loads. | Wait for the first submit or for the person to change the field. Then show clear messages. |
| Trust the browser check. | Check again on the server before you save data, take a payment, or log someone in. |
| Show one general error for everything. | Turn the flag that failed into a clear next step. |
Practice exercises
4 EXERCISESA required text field is empty. Type the name of the flag that becomes true.
console.log("valueMissing");console.log("valueMissing");An empty required field fails the required rule, so the flag valueMissing is true.
Read the code. It sets an error on the confirm field when the passwords don't match. What exact string removes that error?
const password = form.elements.password;
const confirm = form.elements.confirm;
function validatePasswords() {
const message = password.value === confirm.value ? "" : "Passwords must match";
confirm.setCustomValidity(message);
}
password.addEventListener("input", validatePasswords);
confirm.addEventListener("input", validatePasswords);const password = form.elements.password;
const confirm = form.elements.confirm;
function validatePasswords() {
const message = password.value === confirm.value ? "" : "Passwords must match";
confirm.setCustomValidity(message);
}
password.addEventListener("input", validatePasswords);
confirm.addEventListener("input", validatePasswords);The confirm field holds the error. When the passwords match, setCustomValidity("") turns customError off. When they don't, the message keeps the field invalid.
You want the browser to show its own error bubble for a field. Which method do you call?
reportValidity() asks the browser to show its own error bubble. checkValidity() only tests, though it still fires invalid.
A submit fails with several errors. Which invalid field should get focus first?
function showError(input, message) {
const error = document.querySelector("#" + input.id + "-error");
input.setAttribute("aria-invalid", message ? "true" : "false");
input.setAttribute("aria-describedby", error.id);
error.textContent = message;
}function showError(input, message) {
const error = document.querySelector("#" + input.id + "-error");
input.setAttribute("aria-invalid", message ? "true" : "false");
input.setAttribute("aria-describedby", error.id);
error.textContent = message;
}The code links the field to its error text with aria-describedby and marks it with aria-invalid. After a failed submit, move focus to the first invalid field, or to an error summary that links to it.
Check your understanding
7 QUESTIONSQuestion 1 of 7An optional text field has
pattern="[A-Z]{3}", and it is empty. What happens with the pattern rule?Choose an answer to see the explanation.
Question 2 of 7What does this message check print?
Read the code, then predictconst message = "Passwords must match"; console.log(message === "" ? "valid" : "customError");Choose an answer to see the explanation.
Question 3 of 7Which method gives back
trueorfalseand firesinvalid, but shows nothing to the person?Choose an answer to see the explanation.
Question 4 of 7What does this first-flag function print?
Read the code, then predictfunction firstFlag(flags) { return flags.valueMissing ? "valueMissing" : flags.valid ? "valid" : "other"; } console.log(firstFlag({ valueMissing: true, valid: false }));Choose an answer to see the explanation.
Question 5 of 7For which fields is
willValidateusuallyfalse?Choose an answer to see the explanation.
Question 6 of 7Why add
novalidatewhen you show your own error messages?Choose an answer to see the explanation.
Question 7 of 7What does this browser-and-server check print?
Read the code, then predictconst serverMustCheck = true; const browserHelpful = true; console.log(browserHelpful && serverMustCheck ? "both" : "browser only");Choose an answer to see the explanation.
Key takeaways
- HTML rules such as
requiredandpatterngive you a lot of validation before you write any JavaScript. validityis a checklist of flags.validistrueonly when no rule fails.setCustomValidity("message")turns oncustomError.setCustomValidity("")turns it off.checkValidity()only tests.reportValidity()tests and shows the browser's error.- Your own error messages must work for everyone, and the server must check again.
Form validation is checking form values against rules. The browser helps people fix mistakes first, and the server makes the final check.
Up next: FormData.