Module · Level 2 · proposed direction

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.

Proposed direction · Level 2

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.validity with typed flags (valueMissing, typeMismatch, rangeOverflow…).
  • Methods (checkValidity, reportValidity), validationMessage, the invalid event, and the CSS :valid/:invalid/:user-invalid family.
  • 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.”
Included in the HTML Next library

The official @nextwebwg/html reference library includes this generalized validation layer today. Its public validate(value, constraint) function implements required, scalar type, keyword enum, range, length, pattern, and step checks and returns the open error list described below. Its DOM adapter delegates to native setCustomValidity() where available, maintains validity and accessibility state on every other element, dispatches invalid, and rewrites authored :valid/:invalid selectors to include the internal compatibility state. Authors use the proposed surface; the library carries the browser-compatibility work.

Why it doesn't generalize yet

GapConsequence
setValidity is gatedOnly form-associated custom elements get ElementInternals. An ordinary element or a non-control component has no path to it.
The flag set is closedBecause 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 sourceYou 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.validity is 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, generalizing checkValidity(), so there is no validate(value, type) threading. It returns the rich result and fires invalid.
  • 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 name ElementInternals already uses, and adds a reason list (a value can fail more than one way) and a path, so a structured failure can point at the field, something native ValidityState cannot express.

The standalone validate(value, type | schema) stays a pure helper for validating raw data that is not attached to an element.

Where “custom” survives

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:

reasonwhennative flag (interop)
missingrequired, but emptyvalueMissing
typewrong type (email, number, <color>…)typeMismatch
rangebelow min / above maxrangeUnderflow / rangeOverflow
lengthshorter / longer than allowedtooShort / tooLong
patternfails a patternpatternMismatch
stepoff the step gridstepMismatch
unparseablecannot be parsed to the typebadInput
schema reasona schema rule with no native flagcustomError (legacy fallback only)
This is the schema, made native

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.validity is 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-invalid already 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. 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. While browsers lack the generalized pseudo-class, the official library translates that selector and maintains the corresponding interaction state; application CSS does not change.

One author-facing surface

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.

Authors write the platform syntax

Authors set validity through setValidity() and style :valid, :invalid, and :user-invalid. They do not target polyfill attributes. The official library preserves that contract:

  • native form controls → real setCustomValidity(), so real :invalid and form submission still work;
  • form-associated custom elements → real ElementInternals.setValidity();
  • every other element → the same public validity object and event, the applicable ARIA state, and internal mirrored state;
  • author CSS → selectors using the validity pseudo-classes are transformed to match either the browser state or that internal mirror.

The internal representation is an implementation detail. When browsers expose validity on arbitrary elements, the selector transform and mirrored state disappear without changing component source or application CSS.

References

  1. WHATWG HTML, the Constraint Validation API and ValidityState.
  2. WHATWG HTML, ElementInternals.setValidity() (the existing, customless set primitive, gated to form-associated custom elements).
  3. WAI-ARIA, aria-invalid (the invalid-state signal that applies to any element).
  4. CSS Selectors Level 4, validity pseudo-classes (:valid, :invalid, :user-invalid).
  5. IETF JSON Schema; Zod / Valibot (the issue-list model this mirrors).