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.
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.
Related Guides
- Constraint Validation API Deep Dive — the full surface of
validity,willValidate, and theinvalidevent this recipe builds on. - Custom Validity Messages — overriding native copy with
setCustomValidity()and the discipline of clearing it. - checkValidity vs reportValidity Differences — choosing the silent vs UI-triggering evaluation method around your flag reads.
- Asynchronous Server Checks — folding server-side results into the same validity pipeline via
customError.