The Form Validation API You Already Shipped
Between "just use the required attribute" and "npm install a validation library" sits a layer most developers never explore: the browser's built-in validation engine has a complete JavaScript API, and it does 90% of what the libraries do.
The attributes are the rules; the API is the control
<form id="signup" novalidate>
<input name="username" required minlength="3" pattern="[a-z0-9_]+">
<input name="email" type="email" required>
</form>
novalidate is the counterintuitive key move: it disables the browser's default bubbles and timing while keeping all the validation state - so you decide when and how errors show:
form.addEventListener('submit', (e) => {
if (!form.checkValidity()) {
e.preventDefault();
for (const field of form.elements) {
showError(field); // your markup, your styling, your timing
}
form.querySelector(':invalid')?.focus();
}
});
function showError(field) {
const box = field.parentElement.querySelector('.error');
if (box) box.textContent = field.validationMessage; // localized, human, free
}
field.validationMessage is the browser's own message - "Please fill out this field", in the user's language, for every rule. You get i18n'd error copy without writing any.
validity: know exactly what's wrong
field.validity.valueMissing; // required but empty
field.validity.patternMismatch; // failed the regex
field.validity.tooShort; // under minlength
field.validity.typeMismatch; // not a valid email/url
Branch on these for custom copy per failure ("Usernames are lowercase letters, numbers and _") while keeping the engine's logic. Pair with the :user-invalid CSS pseudo-class (or :has() on the wrapper) so styling needs no JS at all.
setCustomValidity: plug in your own rules
Anything the attributes can't express - including async checks - joins the same system:
username.addEventListener('blur', async () => {
username.setCustomValidity(''); // clear first - always
if (!username.validity.valid) return; // built-in rules first
const taken = await isUsernameTaken(username.value);
if (taken) username.setCustomValidity('That username is taken.');
username.reportValidity(); // show it now
});
A non-empty custom validity makes the field invalid to everything - checkValidity, :invalid, form submission. The one trap: forgetting to reset with setCustomValidity('') leaves the field permanently invalid; clear-then-check, every time.
Password-confirmation matching, "date must be in the future", inventory checks - they all fit this shape. The library becomes worth it when you need schema-driven forms or cross-field dependency graphs; until then, the platform's engine plus thirty lines of glue is smaller, faster and already speaks 100 languages.
Full-stack web developer sharing practical tutorials and building tools that ship.
Got something on your mind?
My inbox is open - no forms disappearing into the void here.
- Just say hello Found a tutorial useful? Spotted a mistake? Tell me.
- Hire me for a project Have something custom in mind? Let's talk scope and timelines.
- Product support Bought something here? I'll help you get it running.
I usually reply within 1-2 business days.