Constraint Validation API Deep Dive
The Constraint Validation API is the browser’s built-in bridge between declarative HTML constraint attributes and imperative JavaScript control, exposing a read-only ValidityState object on every form control plus the checkValidity(), reportValidity(), and setCustomValidity() methods that let you evaluate and surface errors on your own terms. Mastering this API lets you delete heavyweight third-party validation bundles, run constraint checks synchronously on the main thread without forced reflows, and inherit accessible focus management for free.
This guide treats native validation as the foundation layer of the broader Mastering HTML5 Native Form Validation approach: declarative attributes describe the rules, the API reports the state, and you decide when and how the user sees feedback. The house pattern throughout is a <form novalidate> element whose submit handler calls checkValidity() and reportValidity() explicitly, so the browser never throws an uncontrolled popup at the user mid-keystroke.
reportValidity() produces visible UI and moves focus.Prerequisites and Mental Model
Before wiring up imperative validation, confirm the declarative groundwork is in place. The API does nothing useful unless your markup carries real constraints and your controls are actually candidates for validation.
| Prerequisite | Why it matters | How to verify |
|---|---|---|
| Constraint attributes on inputs | ValidityState flags are derived from required, type, pattern, min/max, step, minlength/maxlength |
Inspect the element; flags stay false if no constraints exist |
Controls are not disabled |
Disabled controls are barred from validation and always report valid | element.willValidate returns true |
Form uses novalidate |
Suppresses uncontrolled native popups so you call reportValidity() deliberately |
form.noValidate === true |
| A submit handler exists | Native blocking is off, so you must gate submission in JS | submit listener calls checkValidity() |
Stable ids on inputs |
Required to wire aria-describedby to error containers |
Each input has a unique id |
The canonical baseline is small. A novalidate form runs your submit handler, which performs a single synchronous checkValidity() gate and only escalates to reportValidity() when a real user action (the submit) warrants visible feedback.
/**
* Canonical baseline: novalidate form, manual checkValidity gate,
* reportValidity only on explicit submit.
*/
const form = document.querySelector<HTMLFormElement>('#registration-form');
if (form) {
form.addEventListener('submit', (event: SubmitEvent) => {
// Synchronous pass over every constraint, no UI side effects yet.
if (!form.checkValidity()) {
event.preventDefault();
// Escalate to native UI exactly once, on the user's submit action.
form.reportValidity();
return;
}
event.preventDefault();
submitFormData(new FormData(form));
});
}
ValidityState Flags: The API Reference
HTML5 constraint attributes are not styling hooks; the browser serializes them into a read-only ValidityState object attached to every form-associated element. Each boolean reflects exactly one failure mode, and the convenience valid flag is true only when every other flag is false. Reading these flags individually is the basis of reading ValidityState flags for granular errors, where each flag maps to a precise, human-readable message.
| Flag | Set when | Driven by |
|---|---|---|
valueMissing |
A required control is empty |
required |
typeMismatch |
Value is not a valid email/url |
type="email", type="url" |
patternMismatch |
Value fails the pattern regex |
pattern |
tooShort |
Value shorter than minlength (after edit) |
minlength |
tooLong |
Value longer than maxlength (after edit) |
maxlength |
rangeUnderflow |
Numeric/date value below min |
min |
rangeOverflow |
Numeric/date value above max |
max |
stepMismatch |
Value not aligned to the step grid |
step |
badInput |
Browser cannot parse the value at all | e.g. letters in type="number" |
customError |
You called setCustomValidity('non-empty') |
setCustomValidity() |
valid |
All of the above are false |
derived |
The mapping is live and bidirectional. Reassigning an attribute via setAttribute() or a direct property triggers immediate re-evaluation of the corresponding flag, with native type coercion: numeric inputs parse strings to numbers, date inputs to date-comparable strings, and pattern matches against the raw string.
interface ConstraintSnapshot {
isValid: boolean;
flags: Record<keyof Omit<ValidityState, 'valid'>, boolean>;
}
/**
* Reads the complete validation state of a control with no library overhead.
*/
function readConstraintState(element: HTMLInputElement): ConstraintSnapshot {
const v = element.validity;
return {
isValid: v.valid,
flags: {
valueMissing: v.valueMissing,
typeMismatch: v.typeMismatch,
patternMismatch: v.patternMismatch,
tooShort: v.tooShort,
tooLong: v.tooLong,
rangeUnderflow: v.rangeUnderflow,
rangeOverflow: v.rangeOverflow,
stepMismatch: v.stepMismatch,
badInput: v.badInput,
customError: v.customError,
},
};
}
/**
* Dynamic constraint injection. Direct property assignment is preferred
* over setAttribute for numeric values and forces immediate recalculation.
*/
function applyDynamicRange(input: HTMLInputElement, min: number, max: number): void {
input.min = String(min);
input.max = String(max);
input.step = '1';
console.log('Recalculated state:', readConstraintState(input));
}
For how each input type serializes into these flags, see the HTML5 Input Types & Attributes reference; for regex-driven patternMismatch specifically, the HTML5 pattern attribute regex examples catalogue covers the common cases.
Step-by-Step: Building a Controlled Validation Layer
The following sequence assembles a production validation layer from the three API methods. Each step is independently runnable.
Step 1 — Gate submission silently, report once
checkValidity() returns a boolean without showing UI; reportValidity() returns the same boolean and shows native tooltips while focusing the first invalid control. The full behavioral contrast is covered in checkValidity vs reportValidity differences, but the rule of thumb is: check silently as often as you like, report only on explicit user action.
function gateSubmission(form: HTMLFormElement): boolean {
// Silent pass first — cheap, no layout, no focus change.
if (form.checkValidity()) return true;
// One visible escalation, tied to the submit gesture.
form.reportValidity();
return false;
}
Step 2 — Validate per step without disrupting the user
In wizard interfaces, validate only the current step’s fields silently so navigation never triggers an uncontrolled popup. This pattern integrates with the broader Form Submission Lifecycle.
class StepValidator {
private form: HTMLFormElement;
constructor(formId: string) {
this.form = document.querySelector<HTMLFormElement>(`#${formId}`)!;
}
/** Silent per-step validation; returns the first failing field or null. */
validateStep(step: number): HTMLInputElement | null {
const fields = this.form.querySelectorAll<HTMLInputElement>(`[data-step="${step}"]`);
for (const field of fields) {
if (!field.checkValidity()) return field;
}
return null;
}
advance(step: number): boolean {
const firstInvalid = this.validateStep(step);
if (firstInvalid) {
firstInvalid.reportValidity(); // Surface only the one blocking field.
return false;
}
return true;
}
}
Step 3 — Inject business rules with setCustomValidity
setCustomValidity(message) flips the customError flag on; passing '' clears it and restores native evaluation. Custom errors must be cleared on every pass, then reapplied only on failure, or the field stays permanently invalid. The disciplined lifecycle lives in how to use setCustomValidity correctly, and message authoring in Custom Validity Messages.
function applyBusinessRule(
field: HTMLInputElement,
predicate: (value: string) => boolean,
message: string,
): boolean {
field.setCustomValidity(''); // Always clear first.
if (!field.checkValidity()) return false; // Native constraint already failed.
if (!predicate(field.value)) {
field.setCustomValidity(message); // Reapply only when the rule fails.
return false;
}
return true;
}
Step 4 — Mirror validity into accessible DOM state
Native UI is not enough for assistive technology when you suppress it with novalidate. Mirror each flag into aria-invalid and an aria-describedby-linked container so screen readers announce errors. The accessibility patterns here align with UX Patterns & Error State Design.
function syncAccessibleState(input: HTMLInputElement): void {
const errorId = `${input.id}-error`;
let errorEl = document.getElementById(errorId);
if (!errorEl) {
errorEl = document.createElement('div');
errorEl.id = errorId;
errorEl.setAttribute('role', 'status');
errorEl.setAttribute('aria-live', 'polite');
input.insertAdjacentElement('afterend', errorEl);
}
const valid = input.validity.valid;
input.setAttribute('aria-invalid', String(!valid));
if (!valid) {
input.setAttribute('aria-describedby', errorId);
errorEl.textContent = input.validationMessage;
} else {
input.removeAttribute('aria-describedby');
errorEl.textContent = '';
}
}
State Management, Debouncing, and Race Conditions
Real-time validation must run silently and be throttled. Validating on every keystroke with reportValidity() forces synchronous layout, steals focus, and flickers tooltips. Debounce input to 300–500ms, validate with checkValidity(), and reserve reportValidity() for blur and submit. When validation reaches the network, cancel stale work with an AbortController per the asynchronous server checks approach.
class RealtimeValidator {
private timers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(private form: HTMLFormElement) {
// Event delegation covers dynamically injected controls for free.
this.form.addEventListener('input', (e) => this.onInput(e), { passive: true });
this.form.addEventListener('blur', (e) => this.onBlur(e), true);
this.form.addEventListener('submit', (e) => this.onSubmit(e));
}
private onInput(event: Event): void {
const target = event.target as HTMLInputElement;
if (!target.form) return;
const key = target.name || target.id;
clearTimeout(this.timers.get(key));
this.timers.set(
key,
setTimeout(() => {
target.checkValidity(); // Silent — never report on input.
syncAccessibleState(target);
this.timers.delete(key);
}, 350),
);
}
private onBlur(event: Event): void {
const target = event.target as HTMLInputElement;
if (target.form) syncAccessibleState(target);
}
private onSubmit(event: SubmitEvent): void {
if (!this.form.checkValidity()) {
event.preventDefault();
this.form.reportValidity();
}
}
}
The invalid event fires synchronously during reportValidity() and during any submit attempt. Listening in the capture phase lets you preventDefault() it to suppress native UI entirely while you render your own, without disabling the underlying constraint evaluation.
form.addEventListener(
'invalid',
(event) => {
event.preventDefault(); // Suppress the native tooltip, keep the flags.
const field = event.target as HTMLInputElement;
syncAccessibleState(field);
},
true, // Capture phase: intercept before the browser shows its UI.
);
Accessibility Compliance
Suppressing native UI means you own the WCAG obligations the browser used to satisfy. The minimum contract: announce errors, associate them programmatically, and route focus deterministically.
- WCAG 3.3.1 Error Identification — Every invalid field carries
aria-invalid="true"and points at a visible text message viaaria-describedby. - WCAG 4.1.3 Status Messages — Errors appear in a live region (
role="status"/aria-live="polite", orrole="alert"for blocking failures) so they are announced without moving focus. - WCAG 2.4.3 Focus Order — On submit failure, move focus to an error summary or the first invalid control; never manipulate
tabindexto skip fields, which breaks keyboard expectations.
/** Builds a focusable error summary that links to each invalid field. */
function buildErrorSummary(form: HTMLFormElement): void {
let summary = document.getElementById('form-error-summary');
if (!summary) {
summary = document.createElement('div');
summary.id = 'form-error-summary';
summary.setAttribute('role', 'alert');
summary.setAttribute('tabindex', '-1');
form.insertAdjacentElement('beforebegin', summary);
}
summary.replaceChildren();
const invalid = form.querySelectorAll<HTMLInputElement>(':invalid');
invalid.forEach((field) => {
const link = document.createElement('a');
link.href = `#${field.id}`;
link.textContent = field.validationMessage || 'Invalid input';
link.addEventListener('click', (e) => {
e.preventDefault();
field.focus();
});
summary!.appendChild(link);
});
if (invalid.length > 0) summary.focus();
}
Common Gotchas
1. Reading tooShort on an empty field. tooShort and tooLong only fire after the user edits the value, not for programmatically set or pristine values. Do not rely on them as a substitute for required.
// ❌ Assumes minlength alone catches an empty required field.
if (input.validity.tooShort) showError();
// ✅ Check valueMissing for emptiness, tooShort for partial input.
if (input.validity.valueMissing || input.validity.tooShort) showError();
2. Forgetting to clear customError. A stale custom message blocks submission forever because customError never auto-clears.
// ❌ Only ever sets, never resets — field is stuck invalid.
if (mismatch) confirm.setCustomValidity('Passwords do not match.');
// ✅ Clear unconditionally, then reapply only on failure.
confirm.setCustomValidity('');
if (mismatch) confirm.setCustomValidity('Passwords do not match.');
3. Validating disabled controls. Disabled controls always report valid because willValidate is false. If a field must be validated, use readonly plus a guard instead of disabled.
// ✅ Skip non-candidate controls explicitly rather than trusting the result.
const candidates = [...form.elements].filter(
(el): el is HTMLInputElement => el instanceof HTMLInputElement && el.willValidate,
);
4. Expecting validation to cross shadow boundaries. The API does not pierce shadow DOM. Attach validation inside each shadowRoot or use composedPath() for slotted controls.
function attachInsideShadow(host: HTMLElement, selector: string): void {
host.shadowRoot
?.querySelectorAll<HTMLInputElement>(selector)
.forEach((control) =>
control.addEventListener('invalid', (e) => {
e.preventDefault();
control.reportValidity();
}),
);
}
Browser Compatibility
| Feature | Chrome/Edge | Firefox | Safari | Mobile Safari |
|---|---|---|---|---|
checkValidity() / reportValidity() |
Full | Full | Full | Full |
ValidityState flags |
Full | Full | Full | Full |
setCustomValidity() |
Full | Full | Full | Full |
:invalid / :user-invalid |
Full | Full | Full (recent) | Full (recent) |
| Native tooltip positioning | Consistent | Consistent | Occasional misplacement on transformed inputs | Occasional |
invalid ARIA live announcement |
Yes | Yes | Delayed | Delayed |
Across engines the flag logic is uniform; divergence is concentrated in native UI rendering and announcement timing — which is precisely why suppressing native UI with novalidate and rendering your own accessible messages produces the most consistent result.
Frequently Asked Questions
Does checkValidity() fire the invalid event?
Yes. Per the HTML specification, checkValidity() dispatches an invalid event on every invalid control. What it does not do is show native UI or move focus — that is exclusive to reportValidity(). Listening for invalid in the capture phase and calling preventDefault() is how you suppress the native popup while keeping the flags intact.
Why do tooShort and tooLong stay false on a pre-filled field?
By design, the length constraints only apply to values the user has edited, not to values set programmatically or present on load. This prevents false errors on server-rendered defaults. If you need to enforce length on initial data, run your own length comparison rather than relying solely on those two flags.
Should I keep novalidate if I want to use these methods?
Yes. novalidate only suppresses the browser's automatic blocking popups on submit; it leaves the entire Constraint Validation API — flags, checkValidity(), reportValidity(), setCustomValidity() — fully operational. It is the house pattern precisely because it hands you complete control over when feedback appears.
How do I validate a control inside a Web Component?
The Constraint Validation API does not automatically traverse shadow boundaries. Either query controls inside the component's shadowRoot and attach validation there, or adopt the ElementInternals form-associated custom element API so the component participates in its host form's validation directly.
Is the valid flag computed or stored?
validity.valid is derived: it is true only when every other flag is false. You never set it directly. To make a field invalid programmatically, flip customError via setCustomValidity(); to make it valid again, clear that message with an empty string.
Related Guides
- checkValidity vs reportValidity Differences — when to choose the silent check over the UI-triggering report.
- Reading ValidityState Flags for Granular Errors — turn each boolean flag into a precise message.
- Custom Validity Messages — author and localize the strings you feed to
setCustomValidity(). - Form Submission Lifecycle — sequence the submit gate, interception, and async send.
- HTML5 Input Types & Attributes — the declarative constraints that populate the flags.
← Back to Mastering HTML5 Native Form Validation