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.

ValidityState flags feeding checkValidity and reportValidity A form control populates ten ValidityState boolean flags. checkValidity reads them silently and returns a boolean; reportValidity reads the same flags, returns a boolean, and additionally shows native UI and moves focus. form control required, pattern… ValidityState valueMissing typeMismatch patternMismatch tooShort / tooLong rangeUnderflow rangeOverflow stepMismatch badInput customError valid (derived) checkValidity() returns boolean silent, no focus reportValidity() returns boolean shows native UI focuses first invalid
Both methods read the identical ValidityState flags; only 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 via aria-describedby.
  • WCAG 4.1.3 Status Messages — Errors appear in a live region (role="status"/aria-live="polite", or role="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 tabindex to 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.

← Back to Mastering HTML5 Native Form Validation

Explore This Section