Form Validation
This lesson explains how to validate form-associated custom elements using ElementInternals, the Constraint Validation API, custom error messages, and accessible validation feedback.
Why Form Validation Matters in Web Components
Custom form controls must behave like native inputs: block invalid
submission, expose validation state to the parent form, and show
helpful error messages. Without validation integration, a custom
element looks like a field but does not participate in form
behavior.
Form-associated custom elements use
ElementInternals to report validity through the same
browser APIs that power native <input> and
<select> elements.
| Concept | Role in Validation |
formAssociated | Opts the element into form participation |
attachInternals() | Provides the validation and form APIs |
setValidity() | Reports valid or invalid state to the form |
checkValidity() | Checks validity without showing UI |
reportValidity() | Checks validity and shows the error message |
ValidityState | Flags such as valueMissing and customError |
Basic Validated Custom Input
class AppEmailInput extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals = this.attachInternals();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<input type="email" part="input" />
<span part="error" hidden></span>
`;
this.input = this.shadowRoot.querySelector("input");
this.error = this.shadowRoot.querySelector("[part=error]");
this.input.addEventListener("input", () => this.validate());
this.input.addEventListener("blur", () => this.validate(true));
}
validate(showMessage = false) {
const value = this.input.value.trim();
const isValid = value.includes("@");
this.internals.setValidity(
{ customError: !isValid },
isValid ? "" : "Enter a valid email address.",
this.input
);
this.internals.setFormValue(value);
this.error.hidden = isValid;
this.error.textContent = isValid ? "" : "Enter a valid email address.";
this.input.setAttribute("aria-invalid", String(!isValid));
if (showMessage && !isValid) {
this.internals.reportValidity();
}
}
}
customElements.define("app-email-input", AppEmailInput);
<form>
<app-email-input name="email" required></app-email-input>
<button type="submit">Save</button>
</form>
The component validates on input, reports state through
setValidity(), and participates in native form
submission.
Using setValidity()
this.internals.setValidity(
{ customError: true },
"Username must be at least 3 characters.",
this.input
);
setValidity() takes three arguments: a
ValidityState flags object, a custom error message,
and an optional anchor element for focus when
reportValidity() runs.
| Validity Flag | Meaning |
valueMissing | Required field is empty |
typeMismatch | Value does not match expected type |
patternMismatch | Value fails a pattern rule |
tooShort / tooLong | Length constraints failed |
rangeUnderflow / rangeOverflow | Numeric min/max failed |
customError | Your own validation rule failed |
Clearing Validation Errors
if (isValid) {
this.internals.setValidity({});
this.error.hidden = true;
this.input.removeAttribute("aria-invalid");
}
Pass an empty flags object to mark the control valid again. Always
clear visual and ARIA error state when validation passes.
checkValidity() vs reportValidity()
| Method | Behavior | Typical Use |
element.checkValidity() | Returns true or false silently | Pre-submit checks in JavaScript |
element.reportValidity() | Returns result and shows message | User-facing validation on submit or blur |
form.checkValidity() | Validates all controls in the form | Custom submit handlers |
form.reportValidity() | Validates and surfaces first error | Blocking invalid form submission |
Required Field Validation
validate() {
const value = this.input.value.trim();
const required = this.hasAttribute("required");
const isMissing = required && value.length === 0;
this.internals.setValidity(
{ valueMissing: isMissing },
isMissing ? "This field is required." : ""
);
this.internals.setFormValue(value);
}
Mirror native required behavior by setting
valueMissing when the control is empty and the
attribute is present.
Pattern and Length Rules
validatePassword() {
const value = this.input.value;
const minLength = Number(this.getAttribute("minlength")) || 8;
const tooShort = value.length < minLength;
const pattern = this.getAttribute("pattern");
let patternMismatch = false;
if (pattern) {
patternMismatch = !new RegExp(pattern).test(value);
}
const flags = {
tooShort,
patternMismatch
};
const message = tooShort
? `Use at least ${minLength} characters.`
: patternMismatch
? "Password must include letters and numbers."
: "";
this.internals.setValidity(flags, message, this.input);
}
Custom elements can honor the same declarative attributes as
native inputs, keeping the API familiar for form authors.
Async Validation Example
async validateUsername() {
const value = this.input.value.trim();
if (!value) {
this.internals.setValidity(
{ valueMissing: true },
"Username is required."
);
return;
}
const available = await this.checkAvailability(value);
this.internals.setValidity(
{ customError: !available },
available ? "" : "That username is already taken."
);
}
Server-side checks such as username availability still flow
through setValidity(). Debounce async validation to
avoid excessive network requests while the user types.
Accessible Validation Feedback
<input
part="input"
aria-describedby="email-error"
aria-invalid="true"
/>
<span id="email-error" part="error" role="alert">
Enter a valid email address.
</span>
- Set
aria-invalid="true" when the field is invalid. - Link errors with
aria-describedby. - Use
role="alert" or a live region for dynamic messages. - Move focus to the first invalid field on submit when appropriate.
- Do not rely on color alone to indicate errors.
Full Validated Text Field
class AppTextField extends HTMLElement {
static formAssociated = true;
static observedAttributes = ["required", "minlength"];
constructor() {
super();
this.internals = this.attachInternals();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
this.input.addEventListener("input", () => this.validate());
}
attributeChangedCallback() {
this.validate();
}
validate() {
const value = this.input.value.trim();
const required = this.hasAttribute("required");
const min = Number(this.getAttribute("minlength")) || 0;
const flags = {
valueMissing: required && !value,
tooShort: value.length > 0 && value.length < min
};
const message = flags.valueMissing
? "This field is required."
: flags.tooShort
? `Enter at least ${min} characters.`
: "";
this.internals.setValidity(flags, message, this.input);
this.internals.setFormValue(value);
this.updateErrorUI(message, Object.keys(flags).some((k) => flags[k]));
}
updateErrorUI(message, invalid) {
this.error.textContent = message;
this.error.hidden = !invalid;
this.input.toggleAttribute("aria-invalid", invalid);
}
}
Validation Strategies
| Strategy | When to Validate | Best For |
| On submit | Form submit event | Simple forms, fewer distractions |
| On blur | Field loses focus | Text inputs after user finishes typing |
| On input | Every keystroke | Password strength, live feedback |
| Debounced async | After pause in typing | Server availability checks |
When Custom Validation Matters
- You build reusable form controls for a design system.
- Native inputs cannot express your UI or behavior.
- You need the same field to work in multiple frameworks.
- Validation rules must sync with form submission.
- You combine multiple inputs into one composite control.
- Server-side and client-side rules must surface consistently.
Form Validation Best Practices
- Use
formAssociated = true and attachInternals(). - Call
setFormValue() whenever the control value changes. - Report validity with meaningful, specific error messages.
- Clear errors immediately when the value becomes valid.
- Support declarative attributes like
required and pattern. - Expose
checkValidity() and reportValidity() behavior. - Pair visual errors with ARIA attributes for accessibility.
Common Validation Mistakes
- Showing errors without calling
setValidity(). - Forgetting
setFormValue() during validation updates. - Only validating on submit with no field-level feedback.
- Using
customError without a user-facing message. - Leaving
aria-invalid set after the field is fixed. - Running unbounded async checks on every keystroke.
- Building custom validation that bypasses the native form entirely.
Key Takeaways
- Custom form controls should participate in native form validation.
ElementInternals.setValidity() is the core reporting API. ValidityState flags describe why a field failed. reportValidity() connects validation to user feedback. - Accessible error messaging is part of correct validation design.
- Good validation keeps forms predictable across apps and frameworks.
Pro Tip
Treat validation as part of your component's public API. If
form.reportValidity() works with your custom control,
consumers can use the same submit flow they already trust with
native inputs.