Most custom form systems either ignore the browser’s built-in validation entirely — reimplementing required, maxlength and type="email" in JavaScript — or leave it on and end up with native bubbles appearing on top of their own error messages.
There is a middle path that the form validation lifecycle benefits from: let the browser evaluate the constraints you declared in HTML, read its verdict through ValidityState, suppress its UI with novalidate, and render everything through your own error components. Your custom rules join the same channel through setCustomValidity, so the form has one notion of validity.
Context and prerequisites
The Constraint Validation API gives every form control:
el.validity— aValidityStatewith booleans:valueMissing,typeMismatch,patternMismatch,tooShort,tooLong,rangeUnderflow,rangeOverflow,stepMismatch,badInput,customError, and the summaryvalid.el.validationMessage— the browser’s localised message for the first failing constraint.el.checkValidity()— returns the boolean and fires aninvalidevent if false.el.reportValidity()— the same, but also shows the native bubble.el.setCustomValidity(msg)— marks the control invalid with your message (customError); an empty string clears it.
Adding novalidate to the form stops the browser from blocking submission and showing bubbles, but the validity state is still computed. That is the key: you keep the evaluation and discard only the presentation.
The core pattern: native checks, custom rules, one renderer
type Messages = Partial<Record<keyof ValidityState, string>>;
// Your own copy for each native failure, so wording matches the rest of the UI
// instead of varying by browser and OS language.
const DEFAULT_MESSAGES: Messages = {
valueMissing: "This field is required.",
typeMismatch: "Enter a value in the expected format.",
patternMismatch: "Check the format of this value.",
tooShort: "This is too short.",
tooLong: "This is too long.",
rangeUnderflow: "This value is too low.",
rangeOverflow: "This value is too high.",
stepMismatch: "Use a whole number of steps.",
badInput: "Enter a number.",
};
const ORDER: (keyof ValidityState)[] = [
"badInput", "valueMissing", "typeMismatch", "patternMismatch",
"tooShort", "tooLong", "rangeUnderflow", "rangeOverflow", "stepMismatch", "customError",
];
export type FormControl = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
export function messageFor(el: FormControl, overrides: Messages = {}): string | null {
if (el.validity.valid) return null;
for (const key of ORDER) {
if (!el.validity[key]) continue;
// customError carries its own text, set by setCustomValidity below.
if (key === "customError") return el.validationMessage;
return overrides[key] ?? el.dataset[`msg${key[0].toUpperCase()}${key.slice(1)}`] ?? DEFAULT_MESSAGES[key] ?? el.validationMessage;
}
return el.validationMessage;
}
// Custom rules feed the SAME validity model via setCustomValidity.
export function applyCustomRule(el: FormControl, rule: (value: string) => string | null) {
// Clear first: a stale custom error would make every native flag irrelevant
// (customError keeps validity.valid false until explicitly cleared).
el.setCustomValidity("");
if (!el.validity.valid) return; // native failure takes precedence
const msg = rule(el.value);
if (msg) el.setCustomValidity(msg);
}
export function validateForm(form: HTMLFormElement, rules: Record<string, (v: string) => string | null>) {
const errors: Record<string, string> = {};
for (const el of Array.from(form.elements) as FormControl[]) {
if (!el.name || !("validity" in el) || !el.willValidate) continue; // willValidate is false for disabled/readonly
if (rules[el.name]) applyCustomRule(el, rules[el.name]);
const msg = messageFor(el);
if (msg) errors[el.name] = msg;
}
return errors;
}
<form novalidate>
<label for="age">Age</label>
<input id="age" name="age" type="number" min="18" required
data-msg-range-underflow="You must be 18 or over to apply.">
</form>
Step-by-step walkthrough
- Declare constraints in HTML.
required,type,pattern,min,max,minlength,maxlengthandstepare free, fast and understood by assistive technology —requiredalso sets the accessible “required” state. - Add
novalidateto the form. Evaluation continues; bubbles and submit-blocking stop. - Map
ValidityStateflags to your own messages. Keep a default table and allow per-field overrides viadata-msg-*attributes, so wording stays consistent across browsers and matches the guidance in writing error messages that tell the reader what to do. - Route custom rules through
setCustomValidity. Always clear first, and let native failures win so the user fixes the basic format before seeing a business rule. - Skip controls where
willValidateis false. That flag already encodes the disabled, readonly andtype="hidden"rules from skipping validation for disabled and hidden fields. - Style with
:user-invalid, not:invalid.:invalidmatches a required empty field on page load;:user-invalidmatches only after the user has interacted, which lines up with your own timing rules.
Failure modes and edge cases
1. Forgetting to clear a custom error
setCustomValidity("x") makes the control invalid until you call setCustomValidity(""). If you set it on blur and never clear it, the field stays invalid after the user fixes it. The helper above clears at the start of every run.
2. badInput on number fields
For type="number", typing “1e” gives value === "" with validity.badInput === true. Check badInput before valueMissing, or you will tell the user a field they typed in is empty. The ordering in ORDER handles this; the broader problem is covered in controlled number inputs and intermediate values.
3. pattern is anchored and uses the v flag
The pattern attribute is matched against the whole value (implicitly ^(?:…)$) and, in current browsers, compiled with the v flag, which makes some characters such as unescaped - inside a class, ( or | in classes a syntax error. An invalid pattern is silently ignored. Test patterns in the browser, not only in a regex tester.
4. Custom elements and shadow DOM
Controls inside a shadow root are not in form.elements unless the custom element is form-associated and reports validity through ElementInternals.setValidity. See validating inputs across shadow DOM boundaries.
5. :user-invalid support
:user-invalid is supported in current Chromium, Firefox and Safari. If you must support older browsers, add a class from your own touched state rather than falling back to :invalid, which lights up untouched required fields on load.
Verification checklist
Frequently Asked Questions
Why keep native validation if I have a schema?
Because it runs for free, is evaluated continuously, exposes required to assistive technology, and gives you badInput, which a schema cannot see — for a number input with unparsable text, the value your schema receives is an empty string. The schema then handles the rules HTML cannot express.
Is reportValidity() ever the right choice?
For small internal tools where native bubbles are acceptable, yes: it is zero code. For product forms, the bubbles cannot be styled, disappear after a few seconds, are inconsistent across browsers and are not reliably announced, so render your own messages.
Does setCustomValidity work on custom elements?
Not directly. A form-associated custom element uses this.internals.setValidity(flags, message, anchor) instead, which feeds the same validity model the form sees. The rest of this page’s approach applies unchanged.