The Constraint Validation API
setCustomValidity, checkValidity, reportValidity — the JavaScript hooks that let you add your own rules without losing the browser's machinery.
The previous lesson covered everything the browser validates for you. This one covers the JavaScript API for the rules the browser does not know about: passwords matching, a username that is already taken, an end date after a start date.
The mistake people make: when they need a custom rule, they throw the browser's validation away entirely and reinvent it from scratch. The Constraint Validation API lets you add rules without losing what is already there — the focus management, the bubble, the :invalid pseudo, the submit blocking — all of it keeps working.
The validity object
Every form control has a .validity property — an object with one boolean per validation rule. You can read it any time.
const email = document.querySelector('input[name="email"]');
console.log(email.validity);
// {
// valueMissing: false, // required is satisfied
// typeMismatch: false, // looks like an email
// patternMismatch: false, // matches pattern (or no pattern)
// tooLong: false, // under maxlength
// tooShort: false, // over minlength
// rangeUnderflow: false, // over min
// rangeOverflow: false, // under max
// stepMismatch: false, // aligned to step
// badInput: false, // browser couldn't parse the value (e.g., "abc" in type=number)
// customError: false, // setCustomValidity() called with a non-empty string
// valid: true // none of the above
// }Each flag corresponds to one rule. valid is true only when every other flag is false. This is what powers the :valid and :invalid pseudos.
You can use the validity object to figure out exactly why a field failed:
const v = email.validity;
if (v.valueMissing) console.log("Empty");
else if (v.typeMismatch) console.log("Wrong shape");
else if (v.patternMismatch) console.log("Doesn't match pattern");Most forms do not need this much detail — the browser's built-in messages are usually enough. The validity object earns its keep when you want to render your own error UI.
setCustomValidity
The most useful method on a form control is setCustomValidity(message). It tells the browser "this field has a custom error". An empty string clears it.
<form id="signup">
<label>Password <input type="password" name="pw" id="pw" required></label>
<label>Confirm <input type="password" name="pw2" id="pw2" required></label>
<button type="submit">Sign up</button>
</form>
<script>
const pw = document.getElementById('pw');
const pw2 = document.getElementById('pw2');
function check() {
if (pw.value !== pw2.value) {
pw2.setCustomValidity("Passwords don't match");
} else {
pw2.setCustomValidity("");
}
}
pw.addEventListener('input', check);
pw2.addEventListener('input', check);
</script>When the two passwords don't match, the second field has a custom error. The form refuses to submit; the browser shows the message you set; the field is :invalid so your CSS still works. When they match, you clear the error with setCustomValidity("") and the field is valid again.
The trick people miss: always pair every setCustomValidity(message) with a matching setCustomValidity("") when the condition clears. A custom error is sticky — it stays until you explicitly clear it. Forget the clear and the field stays invalid forever.
// broken: never clears the error, so once set, the field is permanently invalid
pw2.addEventListener('input', () => {
if (pw.value !== pw2.value) {
pw2.setCustomValidity("Passwords don't match");
}
// forgot the else branch
});Compare with the fixed version above — the else branch clears the error when the condition no longer holds.
setCustomValidity("") clears only the custom error. Other validity flags (valueMissing, patternMismatch, etc.) are managed by the browser based on the input's attributes — your code does not touch them.
field.setCustomValidity("Passwords don't match") when the values differ. The user fixes the password, but the field stays invalid. The most likely bug is:reportValidity vs checkValidity
Two methods, similar names, different jobs.
form.checkValidity() — returns true if every control passes validation, false otherwise. Quietly. No bubble appears, no field gets focused.
form.reportValidity() — same check, but if it fails, the browser does its full song and dance: focus the first invalid field, show the bubble, fire invalid events.
form.addEventListener('submit', (event) => {
event.preventDefault();
if (form.checkValidity()) {
// every field is valid; submit via fetch yourself
submitForm();
} else {
// surface the errors to the user
form.reportValidity();
}
});checkValidity is the boolean test; reportValidity is the action ("show the user what's wrong"). You almost always pair them: check first, then report if it fails.
There is also field.checkValidity() and field.reportValidity() for individual controls — same semantics, scoped to one input.
A useful picture: checkValidity is the door scanner; reportValidity is the door scanner that also yells at you and lights up the gate when you fail.
invalid event and styling
When a field fails validation (whether at submit or via reportValidity), the browser fires an invalid event on it. Listen for the event and you can run your own logic — log it, render an error elsewhere on the page, anything.
form.addEventListener('invalid', (event) => {
// event.target is the field that failed
const field = event.target;
const errorEl = document.querySelector(`[data-error-for="${field.name}"]`);
if (errorEl) {
errorEl.textContent = field.validationMessage;
}
}, true); // useCapture=true to catch the event from form childrenThe validationMessage property on the field is the same string the browser would show in its bubble — pre-translated, ready to use. By rendering it in your own element, you take control of the error UI without writing your own validation engine.
The capture phase is important here. The invalid event does not bubble up the DOM by default, so a listener on the form sees it only with useCapture=true. Without that flag, you would have to attach the listener to every input.
// Stop the browser's bubble from appearing.
form.addEventListener('invalid', (event) => {
event.preventDefault();
// Now render your own error UI somewhere on the page.
}, true);event.preventDefault() inside the invalid handler stops the browser's default error bubble. Combine that with custom error elements and you have full UI control while the browser still does the validating.
When you take over error rendering this way, focus the first invalid field yourself with field.focus(). Without it, the user has to find the failed field on a long form by themselves — the browser was doing that for you.
fetch only when every field passes validation, but you want the browser to focus the first invalid field and show its bubble if validation fails. Which pair of calls is right?field.setCustomValidity("Username taken") after an async server check. The user changes the username and the field still shows the error. What is the smallest correct fix?invalid event on a form. The handler never fires. What is most likely?