Web Platform · HTML and Semantics
Constraint Validation and the Form Validity State Machine
Required, pattern, min, max, and step produce a set of independent validity flags, not one error. How the browser decides whether a form may submit, and where the API diverges from the interactive path.
A text input with required and pattern="[a-z]+" and an empty value has exactly one problem, but the API reports two flags: valueMissing is true and patternMismatch is false, because an empty string is not tested against the pattern at all. Change the value to ABC and valueMissing becomes false while patternMismatch becomes true. There is no single “error” field anywhere; there is a ValidityState object with ten booleans, and valid is true only when every one of them is false.
That structure matters because it determines what a page can report. A field can fail several constraints simultaneously — a number below min and off the step grid fails both — and the browser’s own message chooses one of them. Code that wants to explain all the problems has to read the flags individually rather than the message.
The constraints and what each one tests
Constraint validation applies to form controls that are candidates for constraint validation. A control is barred when it is disabled, when it is readonly, when it is an input whose type is hidden, button, or reset, when it is a button element that is not a submit button, when it has a datalist ancestor, or when it is an output element. Being hidden by CSS does not bar it. willValidate reports the result directly, and it is false in every one of those cases regardless of which attributes are present.
required fails when a text-like control’s value is the empty string after value sanitisation. A checkbox fails when it is not checked, and a file input fails when its file list is empty. For a select, an empty value means the selected option has no value; a placeholder option with an empty value therefore counts as missing. For a group of radio buttons sharing a name, required on any member requires one of the group to be checked, and the failure is reported on all of them.
pattern is anchored: the whole value must match, as if the source were wrapped in ^(?: and )$. It applies only to text-entry types, and a pattern that does not compile is ignored rather than treated as a failure — the attribute is dropped, and the field validates as if it had none. Recent revisions compile the pattern with the JavaScript v flag, earlier ones with u; the difference shows up in character class syntax, where nested classes and set operations are only legal under v.
minlength and tooShort count UTF-16 code units, and the constraint is only checked on user-edited values, not on values set programmatically. The same asymmetry exists for maxlength, which also truncates user input but does not truncate a value assigned by script. tooLong therefore cannot fire for a value a script wrote, while tooShort cannot fire for one either.
typeMismatch covers two types. An email input must match a valid email address syntax, and with multiple the value is a comma-separated list of them. A url input must be an absolute URL with a scheme. Neither check is a semantic check: a@b passes as an email, and https:// with an empty host does not pass as a URL.
min and max apply to numeric types and to date and time types, and they fail as rangeUnderflow and rangeOverflow. For a date input, the comparison is on the parsed date, and a value such as 2024-02-30 is not a range failure at all: the date state’s value sanitisation algorithm replaces a value that is not a valid date string with the empty string, so rangeUnderflow and rangeOverflow never see it. badInput is separate; it is true when the user’s input could not be converted to a value, and it leaves the other numeric flags alone.
step is the least obvious of the set. The step base is the min value if present, otherwise the value attribute if present, otherwise zero — with the important exception that a base taken from value must itself be on the grid relative to zero, or the base reverts to zero. The step scale is one unit for number, one day for date, one second for datetime-local and time, and so on. step="any" disables the constraint entirely. A value that is on the grid within floating-point tolerance passes; the tolerance is not zero, because decimal fractions such as 0.1 are not exactly representable.
customError is set by setCustomValidity() and cleared by calling it with an empty string. It is sticky: nothing else clears it, and a control with a custom error can never submit until the message is reset to the empty string explicitly.
The flags interact through the value sanitisation that runs before any of them. An input type="number" holding 12abc has no value at all from the control’s point of view, so value returns the empty string, valueMissing fires if required, and badInput fires because the user typed something the control could not parse. Reading value in that state and concluding the field is empty is a common mistake; badInput is the flag that distinguishes “nothing typed” from “unparseable input”.
One state, many messages
The browser produces a message for the first failing constraint it chooses, localised to the user’s language, and exposes it through validationMessage. That string is a user-facing sentence, not a stable identifier, and it changes between engines and versions. Code that inspects it to decide which constraint failed is dependent on wording that was never specified.
The invalid event fires at each control that fails when validation runs, and it does not bubble. Cancelling it does not make the control valid and does not allow submission to proceed, but it does remove that control from the list the browser uses for its own report, so the native message is suppressed for that control while other fields may still be reported. The event is the hook for replacing the native message, not for changing the outcome.
Because validation only runs on submission or on an explicit call, a field is invalid from the moment the page loads if it has constraints it does not satisfy, but no message appears until something asks for one. This is why :invalid matches immediately on an empty required field, and why styling on :invalid alone makes a form look broken before the user has touched it. :user-invalid exists for this: it matches only after the user has interacted with the control, or after an attempt to submit, and it is the selector that matches what a reader expects. Support for it is not universal across older engines, so a fallback that styles :invalid only after a submit attempt is still common.
The submission path, step by step
Submitting a form through the ordinary interactive path runs these steps in order.
The form’s novalidate flag is consulted, along with the formnovalidate attribute on the submitter. A submit button can carry formnovalidate to skip validation for that one action while leaving it in place for the other buttons — the pattern used for “save draft” buttons next to “publish” buttons.
If validation is not suppressed, the browser statically validates the constraints and then interactively validates: it fires invalid at each failing control, then reports the problem for at least one of them, typically focusing and scrolling to the first. If any control failed, submission stops and no submit event is fired.
If validation passes, the submit event is fired on the form. It is cancelable, and cancelling it prevents the navigation or the fetch that would otherwise follow. Only after the event completes does the entry list get constructed from the successful controls — those that have a name and are not disabled, excluding submit, reset, and button controls, unchecked checkboxes and radio buttons, and file inputs with no selected files. The name attribute that makes a control successful is not the label a reader hears; that comes from a separate algorithm, described in Accessible Name Computation: Which Attribute Wins.
Two script entry points diverge from this. form.requestSubmit(submitter) runs the full path, including validation, the invalid events, the submit event, and the submitter’s overridden method or target. form.submit() skips validation and does not fire submit at all, so neither a validation handler nor a submit handler sees it. A form whose only submit path is form.submit() has no client-side validation, whatever the attributes say.
Implicit submission — pressing Enter in a text field — counts as a click on the form’s default button. A form with no submit button at all is submitted implicitly only if it has no more than one field that blocks implicit submission; forms with two or more such fields do nothing on Enter. That rule explains why some forms submit on Enter and structurally identical ones do not.
const form = document.querySelector('form');
// The invalid event does not bubble, so listen for it in the capture phase.
form.addEventListener(
'invalid',
(event) => {
const el = event.target;
// validity exposes each constraint separately
console.log(el.name, {
missing: el.validity.valueMissing,
pattern: el.validity.patternMismatch,
range: el.validity.rangeUnderflow || el.validity.rangeOverflow,
step: el.validity.stepMismatch,
custom: el.validity.customError,
});
},
true,
);
Cross-field rules and their cost
HTML constraints are per control. Anything that depends on two fields — password confirmation, an end date after a start date, a total that matches a line-item sum — has to be expressed through setCustomValidity() on one of them, recomputed whenever either changes. That recomputation is the price of staying inside the native validation model, and it has to run on input rather than on submit, because the custom error has to be cleared before the browser will let the form through.
The alternative is validating in the submit handler and cancelling the event, which gives full control over the message but loses the native focus-and-scroll behaviour and every accessibility benefit that comes with it. A hybrid is common: native constraints for single-field rules, setCustomValidity() for the dependent ones, and a summary region that lists all failures for users who cannot see the per-field messages.
What the browser cannot replace
Client-side validation is a convenience for the person filling the form. The same request can be produced by anything that speaks HTTP, and the constraints are not transmitted with the submission — the entry list contains names and values, nothing about pattern or min. Server-side validation is therefore not a duplicate of the client rules; it is the only enforcement that exists.
One related area where markup semantics carry further than validation is the naming of the controls themselves. A field’s accessible name comes from its label, not from its name attribute, and the precedence rules are their own subject: Accessible Name Computation: Which Attribute Wins.
References
- HTML Standard — Constraint validation — WHATWG
- HTML Standard — Forms — WHATWG
- MDN — Constraint validation — MDN Web Docs