Reading ValidityState Flags for Granular, Per-Constraint Error Messages

Map each boolean flag on an input’s ValidityState object — valueMissing, typeMismatch, patternMismatch, tooShort, tooLong, rangeUnderflow, rangeOverflow, and stepMismatch — to a specific, actionable message, instead of surfacing the browser’s single generic validationMessage. This recipe walks the flags in priority order, emits one tailored string per failing constraint, and wires the result into accessible inline markup.

The native validationMessage string is locale-dependent, terse, and only ever reflects one failure at a time, so a field that is both too short and pattern-mismatched tells the user about whichever constraint the browser checked first. Reading the individual flags yourself lets you control the wording, the priority, and the resolution hint.

When to Use This Recipe

Reach for ValidityState flag inspection when:

  • You need on-brand, consistent wording across browsers and locales rather than the engine’s built-in strings.
  • A single field carries multiple constraints (e.g. required + minlength + pattern) and you want deterministic control over which message wins.
  • You are building a custom error layer and have already adopted the site’s canonical novalidate + reportValidity() baseline described in the Constraint Validation API Deep Dive.
  • You want messages that suggest a fix (“Use at least 8 characters”) rather than restating the rule.

If you only need a yes/no gate or are happy with native strings, plain checkValidity() is enough and this indirection is unnecessary.

ValidityState flag resolution order An input's validity object is inspected flag by flag. valueMissing is checked first, then typeMismatch, then patternMismatch, then length and range flags. The first matching flag selects its message and the lookup stops. Flag resolution (first match wins) input.validity ValidityState valueMissing? required & empty typeMismatch? patternMismatch? length / range flags… granular message render & stop match no
Flags are inspected in a deliberate priority order; the first one that is true selects its message and short-circuits the rest.

Minimal Working Implementation

The baseline mirrors the site’s house style: the form carries novalidate so the browser never shows its own popups, we evaluate constraints ourselves, and we fall back to reportValidity() only as a last-resort native gate. The granular layer is a single ordered lookup from flag name to a message-producing function.

<form id="signup" novalidate>
  <div class="form-field">
    <label for="username">Username</label>
    <input
      id="username"
      name="username"
      type="text"
      required
      minlength="3"
      maxlength="20"
      pattern="[a-z0-9_]+"
      aria-describedby="username-error"
    />
    <p id="username-error" class="error-message" role="alert" hidden></p>
  </div>

  <div class="form-field">
    <label for="age">Age</label>
    <input
      id="age"
      name="age"
      type="number"
      required
      min="18"
      max="120"
      step="1"
      aria-describedby="age-error"
    />
    <p id="age-error" class="error-message" role="alert" hidden></p>
  </div>

  <button type="submit">Create account</button>
</form>
// One message resolver per ValidityState flag. Each receives the element so it can
// read constraint attributes (minLength, min, max, step) and produce a hint.
type FlagResolver = (el: HTMLInputElement) => string;

// Ordered: the first matching flag wins. Put the most "blocking" failure first.
const FLAG_MESSAGES: ReadonlyArray<[keyof ValidityState, FlagResolver]> = [
  ['valueMissing', () => 'This field is required.'],
  ['typeMismatch', (el) =>
    el.type === 'email'
      ? 'Enter a valid email address, e.g. name@example.com.'
      : el.type === 'url'
        ? 'Enter a full URL including https://.'
        : 'The value is not in the expected format.'],
  ['patternMismatch', (el) =>
    el.title || 'Use only the characters allowed for this field.'],
  ['tooShort', (el) =>
    `Use at least ${el.minLength} characters (currently ${el.value.length}).`],
  ['tooLong', (el) =>
    `Use at most ${el.maxLength} characters (currently ${el.value.length}).`],
  ['rangeUnderflow', (el) => `Enter a value of ${el.min} or higher.`],
  ['rangeOverflow', (el) => `Enter a value of ${el.max} or lower.`],
  ['stepMismatch', (el) =>
    `Enter a value in steps of ${el.step || 1}.`],
  ['badInput', () => 'Enter a valid number.'],
];

// Walk the ordered flags and return the first granular message, or '' if valid.
function granularMessage(el: HTMLInputElement): string {
  const validity = el.validity;
  if (validity.valid) return '';
  for (const [flag, resolve] of FLAG_MESSAGES) {
    if (validity[flag]) return resolve(el);
  }
  // customError or an unmapped flag: fall back to the native string.
  return el.validationMessage;
}

// Render the message into the field's live region and toggle ARIA state.
function renderFieldError(el: HTMLInputElement): boolean {
  const message = granularMessage(el);
  const errorEl = document.getElementById(`${el.id}-error`);
  const hasError = message !== '';

  el.setAttribute('aria-invalid', String(hasError));
  if (errorEl) {
    errorEl.textContent = message;
    errorEl.toggleAttribute('hidden', !hasError);
  }
  return !hasError;
}

const form = document.querySelector<HTMLFormElement>('#signup')!;

// Validate a single field on blur for early, non-flashing feedback.
form.addEventListener(
  'blur',
  (e) => {
    const target = e.target;
    if (target instanceof HTMLInputElement) renderFieldError(target);
  },
  true, // capture: blur does not bubble
);

// Gate submission: render every field, focus the first invalid one.
form.addEventListener('submit', (e) => {
  e.preventDefault();
  const inputs = [...form.querySelectorAll<HTMLInputElement>('input')];
  const results = inputs.map(renderFieldError);

  if (results.includes(false)) {
    inputs.find((_, i) => !results[i])?.focus();
    return; // do not submit
  }
  // All granular checks passed — proceed with submission.
  form.submit();
});

Because the form is novalidate, the browser still populates el.validity and el.validationMessage (the Constraint Validation API stays fully active) but never renders its own popups, leaving you in full control of the message text. For the deeper rules on overriding native copy, see Custom Validity Messages, which covers setCustomValidity() and when to clear it.

ValidityState Flag Reference

Every constrained form control exposes a read-only validity object. Each flag is true only when its specific constraint is violated; valid is true only when all flags are false.

Flag True when Triggering attributes/types
valueMissing A required field is empty required
typeMismatch Value doesn’t match the input type’s syntax type="email", type="url"
patternMismatch Value fails the pattern regex pattern
tooShort Value shorter than minlength (only after user edit) minlength
tooLong Value longer than maxlength (rare; UA usually blocks it) maxlength
rangeUnderflow Numeric/date value below min min
rangeOverflow Numeric/date value above max max
stepMismatch Value not aligned to the step grid step, min
badInput The control cannot parse the input (e.g. letters in type="number") numeric/date types
customError A non-empty setCustomValidity() string is set JS-driven
valid No constraint is violated

The FlagResolver signature in the implementation takes the element so each message can read the live constraint values (el.minLength, el.min, el.max, el.step) and the current el.value, producing hints like “currently 2 characters” without hardcoding limits.

Verification

DevTools console. Select a field and inspect its live validity object — spreading it makes every boolean visible at once:

const el = document.querySelector<HTMLInputElement>('#username')!;
console.table({ ...el.validity }); // valueMissing, tooShort, patternMismatch, …, valid
console.log(granularMessage(el));

Playwright. Drive the field into a specific failure and assert the granular copy (not the native string) reaches the live region:

import { test, expect } from '@playwright/test';

test('tooShort yields a character-count hint, not the native message', async ({ page }) => {
  await page.goto('/signup');
  await page.fill('#username', 'ab');
  await page.locator('#username').blur();

  const error = page.locator('#username-error');
  await expect(error).toBeVisible();
  await expect(error).toHaveText('Use at least 3 characters (currently 2).');
  await expect(page.locator('#username')).toHaveAttribute('aria-invalid', 'true');
});

test('required wins over pattern when the field is empty', async ({ page }) => {
  await page.goto('/signup');
  await page.locator('#username').focus();
  await page.locator('#username').blur();
  await expect(page.locator('#username-error')).toHaveText('This field is required.');
});

Asserting against your own strings (rather than validationMessage) keeps the test stable across browser locales — the whole point of reading the flags yourself.

Edge Cases & Failure Modes

1. tooShort only fires after user interaction. Browsers intentionally suppress tooShort for the initial, unedited value so an empty minlength field doesn’t flag immediately on load. If you preload a too-short value programmatically and call checkValidity() without a user edit, tooShort stays false while valid may still be false via other flags. Fix: don’t rely on tooShort to validate prefilled data — run an explicit length check, or dispatch an input event after programmatic value changes.

2. badInput masks the real intent for numeric fields. When a user types letters into type="number", many engines report an empty el.value and set badInput, not typeMismatch. If your resolver omits badInput, granularMessage() falls through to the native string. Always map badInput (as above) for numeric and date inputs.

3. Stale customError silently overrides everything. A leftover setCustomValidity('…') from a previous cycle forces customError to true and valid to false even when all native constraints pass, so granularMessage() returns the stale custom string. Always clear it with el.setCustomValidity('') before re-evaluating — the same reset discipline detailed in Custom Validity Messages. For server-driven validity, integrate this clearing step into your asynchronous server checks so each new response starts from a clean state.

Frequently Asked Questions

Why read individual flags instead of using validationMessage?

validationMessage is a single, browser- and locale-controlled string that reflects only one failing constraint. Reading the ValidityState flags lets you choose the wording, control which failure wins when several apply, and inject live values like the current character count — giving consistent, actionable copy across every engine.

Can more than one flag be true at the same time?

Yes. A numeric field can be both rangeOverflow and stepMismatch, for example. That is exactly why the recipe walks the flags in a deliberate priority order and returns the first match — you decide which failure is most important to surface, rather than letting the engine pick.

Do I still need novalidate when reading flags?

Yes — keep novalidate on the form. It suppresses the browser's native popups while leaving the Constraint Validation API fully active, so el.validity and its flags stay populated. You read the flags, render your own messages, and only call reportValidity() if you ever want the native UI as a deliberate fallback.

← Back to Constraint Validation API Deep Dive