Constraint validation
Three hundred and eighty lines reimplementing what the browser has done natively since 2011.
A colleague asked me to look over a form module because it had got unwieldy. 400 lines, well organized, tested, with a small rules engine in it: required fields, email format, minimum lengths, numeric ranges, and a system for attaching messages to fields.
All of it works. All of it is also in the browser, and has been for years, and the markup it was operating on had none of it.
<input type="text" name="email" class="js-validate" data-rule="required|email">
That’s type="email" required written the long way, minus the free mobile keyboard, minus the autofill, minus the semantics that assistive technology reads, plus 400 lines to maintain.
I’ve written that module myself. Twice. So this is not a post about somebody else being silly, it’s a post about why we all do it, because there’s a real reason and it isn’t ignorance.
What the attributes already do
The constraint validation stuff has been in browsers since about IE10 and it’s substantial:
required for presence. type="email" and type="url" for format. minlength/maxlength for text length, min/max/step for numbers and dates, and pattern for a regex when you genuinely need one.
Then there’s a whole state model on top of it. Every field has a validity object telling you exactly which constraint failed, which is what you need to write a useful message rather than a generic one:
input.validity.valueMissing // required, and empty
input.validity.typeMismatch // type="email" and it isn't one
input.validity.patternMismatch // failed the pattern attribute
input.validity.tooShort // below minlength
input.validity.rangeOverflow // above max
input.validity.customError // you set one yourself
form.checkValidity() // true/false, fires invalid events, no UI
form.reportValidity() // same, plus the browser shows its own bubbles
And CSS gets :required, :optional, :valid, :invalid, :in-range and :out-of-range for free.
Why everybody replaces it anyway
Here’s the honest reason, and it isn’t that people don’t know the attributes exist.
The browser’s built-in error UI is bad, and you can’t fix it.
It’s a bubble, positioned by the browser, unstyleable in any real way. It shows one error at a time, so a form with four problems is four rounds. It vanishes on a timer or when you click. The wording is written by the browser vendor and localized to the browser’s language, not your site’s, so a French user on an English site gets whichever the browser feels like. And on a long form it can scroll to a field and show a bubble in a way that’s disorienting.
Given all that, the reasonable engineer says “fine, I’ll do it myself,” and then finds that doing the messages yourself means doing the rules yourself, because they came as a bundle.
Except they don’t.
novalidate plus the API
The move is to turn off the browser’s interface and keep everything else.
<form novalidate>
<label for="email">Email address</label>
<input id="email" name="email" type="email" required
autocomplete="email" aria-describedby="email-error">
<p class="field__error" id="email-error" role="alert"></p>
</form>
novalidate suppresses the bubbles and the blocking behavior. It does not turn off constraint validation: the attributes still parse, the validity object still populates, :invalid still matches, and checkValidity() still works. It only turns off the browser’s own UI, which is the only part you wanted rid of.
Then the JavaScript is short, because all it’s doing is translating state into your own copy:
const messages = {
valueMissing: 'We need an email address to send your confirmation to.',
typeMismatch: "That doesn't look like an email address. Is the @ missing?"
};
form.addEventListener('submit', e => {
if (form.checkValidity()) return;
e.preventDefault();
for (const field of form.elements) {
const key = Object.keys(messages).find(k => field.validity[k]);
document.getElementById(field.id + '-error').textContent = key ? messages[key] : '';
}
form.querySelector(':invalid').focus();
});
That’s the whole thing. The rules live in the markup where the server, the browser and assistive technology can all see them. The wording lives in one object you can hand to somebody who can write, which was the entire argument of last month’s post.
Cross-field rules, which are the genuine gap in the declarative version, go through setCustomValidity:
confirm.addEventListener('input', () => {
confirm.setCustomValidity(
confirm.value === password.value ? '' : 'These need to match.'
); // the empty string is mandatory; without it the field stays invalid forever
});
That empty-string clear is the classic bug with this API. Set a custom error once and never clear it and the form can never be submitted again, and it looks like a browser fault.
The :invalid gotcha
You will try this and every required field will be red before anyone has typed anything, because an empty required field is invalid, immediately, on load.
Which is correct and useless. Nobody wants to be told off for not having filled in a form they’ve just opened.
There’s no selector yet that means “invalid, and the user has actually interacted with it,” so the fix is a class you control:
/* only after blur or a submit attempt */
.field.is-touched input:invalid,
.form.is-submitted input:invalid { border-color: var(--error); }
Add is-touched on blur, is-submitted on the first failed submit. Slightly annoying, entirely reliable, and I gather there’s a pseudo-class being worked on for this which will make it two lines eventually.
The bits people miss entirely
Two attributes that aren’t validation and are the biggest usability win in this whole area.
autocomplete, with the proper tokens rather than just on and off: autocomplete="given-name", family-name, email, tel, street-address, postal-code, cc-number, new-password. These let the browser fill a whole address in one tap, and they’re the difference between a checkout that takes 40 seconds and one that takes four minutes on a phone. The current accessibility guidelines actually require them on fields collecting personal data, which is a rare case of a rule that makes the thing faster for everybody.
And inputmode, which is newer and worth knowing: inputmode="numeric" gets the number pad without the semantic baggage of type="number", which brings spinners and rejects leading zeros and is wrong for zip codes and card numbers and OTP codes.
And the server, obviously
None of this is security. All of it can be turned off with four seconds of devtools or bypassed entirely by not using a browser.
Client-side validation exists to save people a round trip and to tell them what’s wrong while they’re still looking at the field. The server validates because the server is the only place where validation means anything. Same rules, both ends, and the markup is quite a good place to write them down so they stay in sync.
Sixty lines
That module got to about 60 lines. Mostly the message strings.