Validation
The web already validates, but only inside forms. HTML Next generalizes constraint validation: the same native mechanism that turns required on an <input> into :invalid should validate a typed component prop, a <data> response, or any value against its declared type. The type is the constraint, and the error is native.
At a glance
The idea in three lines: the same validation that already works on a form <input> should work on any typed value, a component prop, a data response, with no forms library.
<!-- a form control validates against its constraints, exactly as today -->
<input bind:value="email" type="email" required>
<!-- a typed component prop validates the same way, with no forms library -->
<x-field :value="draft.age" type="<number>" min="0" max="120">
<!-- structured data validates against a schema; failures carry a path -->
<data name="profile" src="/api/me" schema="/schemas/profile.json">The rest of this page explains why the platform cannot do this yet, and how HTML Next generalizes it.
What the platform already has
HTML has a real validation system, the Constraint Validation API, but it is welded to form controls:
- Declarative constraints on
<input>/<select>/<textarea>:required,pattern,min/max,step,minlength/maxlength,type. ValidityState, the machine-readable result:element.validitywith typed flags (valueMissing,typeMismatch,rangeOverflow…).- Methods (
checkValidity,reportValidity),validationMessage, theinvalidevent, and the CSS:valid/:invalid/:user-invalidfamily. ElementInternals.setValidity(flags, message, anchor), the primitive an element uses to create an error, already exposed to script, and already free of the word “custom.”
Why it doesn't generalize yet
| Gap | Consequence |
|---|---|
setValidity is gated | Only form-associated custom elements get ElementInternals. An ordinary element or a non-control component has no path to it. |
| The flag set is closed | Because the built-in flags are fixed, any script-set error needs an “everything else” bucket, which is why customError / setCustomValidity() exist. “Custom” names the source, not a kind of validity. |
| No schema source | You can say pattern, but not “validate against this type or schema.” Non-form data has no native validation, so userland reaches for Zod or Valibot. |
Valid or invalid, with a reason
Validity is binary: a value satisfies its constraints or it does not. When it does not, it carries an open list of reasons, a reason code plus a message, optionally a path. Because the vocabulary is open and meaningful, there is no “everything else” bucket and therefore no “custom.”
// the element knows its own value and declared type/constraints:
<input bind:value="email" type="email" required>
// validity recomputes reactively when the value changes — usually you read, not call
el.validity // { valid: boolean, errors: ValidityError[] }
el.validationMessage // the first error's message, or ""
// validate on demand (e.g. on submit): the element checks itself, no threading
el.validate() // → { valid, errors }; updates .validity, fires `invalid` if newly invalid
// set validity the type CANNOT derive — async / server-side ("email already taken")
el.setValidity([{ reason: "taken", message: "That email is in use." }])
el.setValidity() // clear the externally-set error
// pure helper: validate any value against a type or schema, no element involved
validate(value, type | schema) // → { valid, errors }
interface ValidityError { reason: string; message: string; path?: string }There are three levels, and the common one is the one you never call:
- Reactive (default). An element with a declared type validates itself when its value changes, because the value is already a reactive dependency.
el.validityis simply current; you read it, you do not drive it. el.validate()for an explicit check, at submit time or on demand. The element validates itself against its own value and type, generalizingcheckValidity(), so there is novalidate(value, type)threading. It returns the rich result and firesinvalid.el.setValidity(errors)only for validity the type cannot derive: an async, server-side answer such as “this email is already taken.” It keeps the customless nameElementInternalsalready uses, and adds a reason list (a value can fail more than one way) and apath, so a structured failure can point at the field, something nativeValidityStatecannot express.
The standalone validate(value, type | schema) stays a pure helper for validating raw data that is not attached to an element.
Only as a documented bridging artifact. When the polyfill drives a legacy native <input>, the sole script hook is setCustomValidity(), and native's closed flag set forces any schema-specific failure into its customError flag. HTML Next's own surface never exposes it; the mapping below marks it as the legacy fallback it is.
The type is the constraint
A declared type or a JSON Schema compiles to validity (the shapes shown at a glance above). One function does it, the parse-and-validate step a schema library performs, but emitting native ValidityState instead of a library-specific error. validate(value, type | schema) maps failures onto meaningful reasons, which map in turn onto the native flags for form interop:
| reason | when | native flag (interop) |
|---|---|---|
missing | required, but empty | valueMissing |
type | wrong type (email, number, <color>…) | typeMismatch |
range | below min / above max | rangeUnderflow / rangeOverflow |
length | shorter / longer than allowed | tooShort / tooLong |
pattern | fails a pattern | patternMismatch |
step | off the step grid | stepMismatch |
unparseable | cannot be parsed to the type | badInput |
| schema reason | a schema rule with no native flag | customError (legacy fallback only) |
This is the mechanism behind “the contract is the schema” (see Types). A typed prop, a bind: input, or a <data> value that fails its declared type produces a native validity error, so schema validation stops being a bundled library and becomes a DOM capability that form controls, components, and data sources all share.
When validation runs, and when errors show
These are two different questions, and conflating them is why validation UIs feel wrong. HTML Next separates them the way the platform already started to:
- Computing validity is reactive: it recomputes whenever the value changes, so
el.validityis always current. No trigger is needed for the value to know whether it is valid. - Showing an error is gated by interaction: you do not flag a field the user has not touched yet. This is exactly what CSS
:user-invalidalready means, invalid and interacted-with, and HTML Next drives the display from that state, not from raw validity.
So the native checkValidity() / reportValidity() split collapses: computing is reactive (or el.validate() on demand), and showing is ordinary CSS on :user-invalid (polyfilled as [data-invalid] once interacted). Submitting a <form> runs validate() across its fields, marks them interacted, and blocks the write when any is invalid, the native form-submission behaviour, now available to typed props and data too.
The surface, and the polyfill's limits
An element with validity should expose the same surface a form control does: the :valid/:invalid/:user-invalid pseudo-classes, an invalid event, validationMessage, and an anchor naming where to show the error. That is the platform change HTML Next proposes: make that surface available to any element with validity, not only form controls.
A polyfill cannot set the real :invalid pseudo-class on an arbitrary element; only the browser can. So the polyfill delegates where it can and shims where it cannot:
- native form controls → real
setCustomValidity(), so real:invalidand form submission still work; - form-associated custom elements → real
ElementInternals.setValidity(); - every other element → a
validityobject,aria-invalid="true"(the accessibility signal that does apply to any element), a[data-invalid]hook for CSS, and a dispatchedinvalidevent.
The shim is exactly what the platform should make unnecessary by exposing validity and the :invalid family on any element. Until then, style polyfilled validity with [data-invalid] / [aria-invalid] rather than :invalid.
References
- WHATWG HTML, the Constraint Validation API and ValidityState.
- WHATWG HTML, ElementInternals.setValidity() (the existing, customless set primitive, gated to form-associated custom elements).
- WAI-ARIA, aria-invalid (the invalid-state signal that applies to any element).
- CSS Selectors Level 4, validity pseudo-classes (
:valid,:invalid,:user-invalid). - IETF JSON Schema; Zod / Valibot (the issue-list model this mirrors).