How to check required field validation
Submit the form with every field empty and read validity.valueMissing on each control. Chrome blocked the submit here, focused the first invalid field and said Please fill out this field. Then type one space into a type="text" field: valueMissing turns false and the server stores a blank.
Why check this
One attribute name covers three decisions: whether the browser blocks the submit, what the control type has already done to the value, and what the route stores. Nothing keeps the three in agreement.
Run this at sign-off on any form that writes a record, and again after a field changes type. The failure it prevents is an account whose display name is one space: the form called it filled, the route agreed, and the row renders blank. Step 8 holds two.
Step 5 catches a failure no manual session finds. A required control whose computed display is none blocks the submit with nothing on screen and one console line. How to test form validation messages covers the message text.
Prerequisites
- Node 22 for the fixture below, or your own form.
- Chrome and
puppeteer-core(npm i puppeteer-core), withCHROMEset to your binary. This run drove Chrome 152.0.7977.76. - curl. Every request goes to
127.0.0.1, so any build answers it. --lang=en-USis in the launch arguments: message text follows the browser UI locale, and this machine returns Ukrainian without it.- The per-control rules are in the HTML standard under the required attribute.
form.html carries one required control of each kind that behaves differently.
<!doctype html>
<meta charset="utf-8">
<title>required fixture</title>
<form id="f" method="post" action="/signup">
<label>Name <input id="name" name="name" type="text" required></label>
<label>Email <input id="email" name="email" type="email" required></label>
<label>Age <input id="age" name="age" type="number" required></label>
<fieldset>
<label><input id="plan-a" name="plan" type="radio" value="basic" required> Basic</label>
<label><input id="plan-b" name="plan" type="radio" value="pro"> Pro</label>
</fieldset>
<label><input id="terms" name="terms" type="checkbox" value="yes" required> Accept terms</label>
<label>Country
<select id="country" name="country" required>
<option value="none">Choose a country</option>
<option value="ua">Ukraine</option>
</select>
</label>
<input id="csrf" name="csrf" type="hidden" required>
<button id="go" type="submit">Create account</button>
</form>
<form id="hf" method="post" action="/signup">
<input id="promo" name="promo" type="text" required hidden>
<button id="hgo" type="submit">Send promo form</button>
</form>
target.mjs serves it and reports three verdicts per payload: key present, value truthy, value survives a trim.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
const PORT = 8931;
const FIELDS = ['name', 'email', 'age', 'plan', 'terms', 'country'];
const rows = [];
const verdicts = (f) => FIELDS.map((k) => {
const v = f.get(k);
return k.padEnd(8)
+ 'present ' + String(f.has(k)).padEnd(6)
+ 'value ' + (v === null ? '(absent)' : JSON.stringify(v)).padEnd(18)
+ 'truthy ' + String(Boolean(v)).padEnd(6)
+ 'trimmed ' + String((v ?? '').trim().length > 0);
});
createServer((req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
if (req.method === 'GET' && url.pathname === '/') {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
return res.end(readFileSync(new URL('form.html', import.meta.url)));
}
if (req.method === 'GET' && url.pathname === '/rows') {
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
return res.end(rows.map((r, i) => 'row ' + (i + 1) + ' ' + r).join('\n') + '\n');
}
if (req.method === 'POST' && url.pathname === '/signup') {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
const f = new URLSearchParams(raw);
const name = f.get('name');
const ok = Boolean(name);
if (ok) rows.push('keys [' + [...f.keys()].join(' ') + '] name ' + JSON.stringify(name));
res.writeHead(ok ? 201 : 400, { 'content-type': 'text/plain; charset=utf-8' });
res.end('status ' + (ok ? 201 : 400) + ' stored ' + ok + '\n'
+ 'raw body: ' + JSON.stringify(raw) + '\n'
+ 'keys sent: [' + [...f.keys()].join(' ') + ']\n'
+ verdicts(f).join('\n') + '\n');
});
return;
}
res.writeHead(404, { 'content-type': 'text/plain' }).end('not found\n');
}).listen(PORT, '127.0.0.1', () => console.log('required-field fixture on http://127.0.0.1:' + PORT));
probe.mjs drives Chrome, one subcommand per step.
import { launch } from 'puppeteer-core';
const CHROME = process.env.CHROME || 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const ORIGIN = 'http://127.0.0.1:8931';
const mode = process.argv[2] || 'inventory';
const pad = (s, w) => String(s).padEnd(w);
const browser = await launch({ executablePath: CHROME, headless: true, args: ['--lang=en-US'] });
const page = (await browser.pages())[0];
const console_ = [];
// the favicon 404 from the fixture is not part of the measurement
page.on('console', (m) => { if (!m.text().includes('Failed to load resource')) console_.push(m.type() + ': ' + m.text()); });
await page.goto(ORIGIN + '/', { waitUntil: 'networkidle2' });
const controls = () => page.evaluate(() => [...document.querySelectorAll('input, select')].map((el) => ({
id: el.id,
kind: el.tagName.toLowerCase() === 'select' ? 'select' : 'input ' + el.type,
attr: el.hasAttribute('required'),
prop: el.required,
willValidate: el.willValidate,
valueMissing: el.validity.valueMissing,
badInput: el.validity.badInput,
value: el.value,
})));
const row = (c) => pad(c.id, 8) + pad(c.kind, 16) + 'required attr ' + pad(c.attr, 6)
+ 'el.required ' + pad(c.prop, 6) + 'willValidate ' + pad(c.willValidate, 6)
+ 'valueMissing ' + pad(c.valueMissing, 6) + 'value ' + JSON.stringify(c.value);
const row2 = (c, typed) => pad(c.id, 8) + 'typed ' + pad(JSON.stringify(typed), 20)
+ 'value ' + pad(JSON.stringify(c.value), 18) + 'valueMissing ' + pad(c.valueMissing, 6)
+ 'badInput ' + c.badInput;
if (mode === 'inventory') {
for (const c of await controls()) console.log(row(c));
console.log('form #f checkValidity ' + await page.evaluate(() => document.getElementById('f').checkValidity()));
} else if (mode === 'empty') {
await page.click('#go');
await new Promise((r) => setTimeout(r, 400));
console.log(await page.evaluate(() => {
const f = document.getElementById('f');
const bad = [...f.elements].filter((e) => e.willValidate && !e.checkValidity());
return 'still on ' + location.pathname
+ '\nfocused after the blocked submit: ' + (document.activeElement.id || '(none)')
+ '\ninvalid controls: ' + bad.map((e) => e.id).join(' ')
+ '\nmessage on ' + bad[0].id + ': ' + JSON.stringify(bad[0].validationMessage)
+ '\nmessage on terms: ' + JSON.stringify(document.getElementById('terms').validationMessage)
+ '\nmessage on plan-a: ' + JSON.stringify(document.getElementById('plan-a').validationMessage);
}));
} else if (mode === 'space') {
for (const id of ['name', 'email', 'age']) await page.type('#' + id, ' ');
for (const c of (await controls()).filter((c) => ['name', 'email', 'age'].includes(c.id))) console.log(row2(c, ' '));
await page.type('#age', 'seventeen');
console.log(row2((await controls()).find((c) => c.id === 'age'), ' seventeen'));
await page.type('#email', 'qa@example.com ');
console.log(row2((await controls()).find((c) => c.id === 'email'), ' qa@example.com '));
} else if (mode === 'radio') {
await page.click('#plan-b');
for (const c of (await controls()).filter((c) => c.id.startsWith('plan'))) console.log(row(c));
await page.click('#terms');
await page.select('#country', 'ua');
for (const c of (await controls()).filter((c) => ['terms', 'country'].includes(c.id))) console.log(row(c));
} else if (mode === 'hidden') {
await page.click('#hgo');
await new Promise((r) => setTimeout(r, 600));
console.log('still on ' + new URL(page.url()).pathname);
console.log(await page.evaluate(() => {
const p = document.getElementById('promo');
const s = getComputedStyle(p);
p.focus();
return 'promo willValidate ' + p.willValidate + ' valueMissing ' + p.validity.valueMissing
+ '\npromo display ' + s.display + ' offsetParent ' + p.offsetParent
+ '\nfocus() moved activeElement to: ' + (document.activeElement.id || document.activeElement.tagName);
}));
console.log('console: ' + (console_.join(' | ') || '(empty)'));
} else if (mode === 'send') {
await page.type('#name', ' ');
await page.evaluate(() => { document.getElementById('f').noValidate = true; });
await Promise.all([page.waitForNavigation({ waitUntil: 'networkidle2' }), page.click('#go')]);
console.log('landed on ' + new URL(page.url()).pathname);
console.log(await page.evaluate(() => document.body.innerText.trim()));
}
await browser.close();
Start the target.
node target.mjs
Steps
- Step 1.
Read the attribute off every control on the page.
node probe.mjs inventoryname input text required attr true el.required true willValidate true valueMissing true value "" email input email required attr true el.required true willValidate true valueMissing true value "" age input number required attr true el.required true willValidate true valueMissing true value "" plan-a input radio required attr true el.required true willValidate true valueMissing true value "basic" plan-b input radio required attr false el.required false willValidate true valueMissing true value "pro" terms input checkbox required attr true el.required true willValidate true valueMissing true value "yes" country select required attr true el.required true willValidate true valueMissing false value "none" csrf input hidden required attr true el.required true willValidate false valueMissing false value "" promo input text required attr true el.required true willValidate true valueMissing true value "" form #f checkValidity falseThree rows disagree with the markup.
plan-bhas no attribute and still reportsvalueMissing: one member covers the radio group.countryreports false because its first option carries a non-empty value.csrfistype="hidden"andwillValidateis false. - Step 2.
Click submit with every field untouched.
node probe.mjs emptystill on / focused after the blocked submit: name invalid controls: name email age plan-a plan-b terms message on name: "Please fill out this field." message on terms: "Please check this box if you want to proceed." message on plan-a: "Please select one of these options."Six controls block and the page does not navigate.
countryis not among them, so a required select is the field nobody notices is unguarded. Focus landed onname, first in document order. - Step 3.
Type one space into the first three fields, then a word into the number.
node probe.mjs spacename typed " " value " " valueMissing false badInput false email typed " " value "" valueMissing true badInput false age typed " " value "" valueMissing true badInput false age typed " seventeen" value "" valueMissing true badInput true email typed " qa@example.com " value "qa@example.com" valueMissing false badInput falseA single space satisfies
requiredontype="text"and fails ontype="email"andtype="number", which sanitize it to the empty string first.seventeenalso empties the number field, withbadInputset besidevalueMissing. - Step 4.
Check the radio with no attribute, then the checkbox, then a country.
node probe.mjs radioplan-a input radio required attr true el.required true willValidate true valueMissing false value "basic" plan-b input radio required attr false el.required false willValidate true valueMissing false value "pro" terms input checkbox required attr true el.required true willValidate true valueMissing false value "yes" country select required attr true el.required true willValidate true valueMissing false value "ua"Checking
plan-bclearedvalueMissingonplan-atoo. The group is one constraint, satisfied by any member, including one the markup never marked. - Step 5.
Submit the second form, whose required field carries the
hiddenattribute.node probe.mjs hiddenstill on / promo willValidate true valueMissing true promo display none offsetParent null focus() moved activeElement to: hgo console: error: An invalid form control with name='promo' is not focusable.The submit is blocked and nothing appears on screen.
focus()leftdocument.activeElementon the button. Comparecsrfin step 1:type="hidden"is exempt, a visible type hidden by CSS is not. - Step 6.
Type one space into the name, turn the form's validation off, and submit.
node probe.mjs sendlanded on /signup status 201 stored true raw body: "name=+&email=&age=&country=none&csrf=" keys sent: [name email age country csrf] name present true value " " truthy true trimmed false email present true value "" truthy false trimmed false age present true value "" truthy false trimmed false plan present false value (absent) truthy false trimmed false terms present false value (absent) truthy false trimmed false country present true value "none" truthy true trimmed trueFive keys reached the route and two did not. An unchecked radio group and checkbox are absent from the body, not empty in it.
countrysentnone. - Step 7.
Send the name field three ways, with no browser in the path.
for p in omitted empty space; do case $p in omitted) set -- ;; empty) set -- --data-urlencode "name=" ;; space) set -- --data-urlencode "name= " ;; esac; echo "== name $p"; curl -s -X POST http://127.0.0.1:8931/signup -d email=qa@example.com "$@" | grep -E '^status|^raw body|^name '; done== name omitted status 400 stored false raw body: "email=qa@example.com" name present false value (absent) truthy false trimmed false == name empty status 400 stored false raw body: "email=qa@example.com&name=" name present true value "" truthy false trimmed false == name space status 201 stored true raw body: "email=qa@example.com&name=+" name present true value " " truthy true trimmed falseThree different requests, two verdicts. The omitted key and the empty value both fail the truthy rule while the bodies differ, and a schema marking the field optional treats them apart. One space is stored.
- Step 8.
Read back what the store kept.
curl -s http://127.0.0.1:8931/rowsrow 1 keys [name email age country csrf] name " " row 2 keys [email name] name " "Two rows, both with a name of one space, one from the form and one from curl. Neither broke any rule.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| willValidate false on a required control | The type is barred from constraint validation | The attribute is decoration there. Find the rule that covers the field on the server. |
| valueMissing false on an untouched <select required> | The first option carries a non-empty value | Set that option's value to the empty string, then re-run step 2. |
| One radio reports valueMissing and its siblings do too | The group is one constraint | Test the group, not the member. Any single member satisfies it. |
| A blocked submit with nothing on screen | An invalid control cannot be focused | Open the console. Step 5 is the reproduction, and the control's computed display was none. |
| valueMissing false on a field holding one space | required counts characters, not meaning | Send the same space past the form, as step 7 does, and read the stored row. |
| valueMissing true on a field the person typed into | The type sanitized the value to the empty string | Read badInput next to it. A missing-value message for a typed value is a separate defect. |
| The route answers the same way to an absent key and an empty value | Its rule is truthiness | Check what the schema does with each. Optional-but-present and absent are different records. |
Common mistakes
What to check next
- How to test that client validation is enforced on the server: the general case behind steps 6 and 7.
- How to check field length limits in a form: the other attribute whose flag never fires.
- How to test whitespace trimming in a form field: which layer should reject the single space.
- Input type email validation: why step 3 answered differently there.
- How to test form validation messages: the wording each control type produces.
FAQ
What does the required attribute do in HTML?
It blocks submission while the control is empty and sets validity.valueMissing. It applies per control, except on radios, where one attribute covers the group. It does nothing on type="hidden".
How do I check if all required fields are filled in JavaScript?
Call form.checkValidity() for a boolean, or [...form.elements].filter((e) => e.validity.valueMissing) for the list. Step 2 uses the filter, which names the fields.
Why does a required select accept its placeholder?
A select is missing only when the selected option's value is the empty string. Step 1 shows a placeholder written value="none" reporting valueMissing false.
Does required reject a single space?
Not on a text input. Step 3 typed one space and valueMissing turned false. type="email" and type="number" strip it first, so they still report missing.
Why did my form stop submitting with no message?
A required control that cannot be focused blocks the submit in silence. Step 5 hides one, and the only sign is a console error.
Verified
Verified by Maks VernyChrome 152.0.7977.76puppeteer-core 25.10.0node 22.23.2curl 8.21.0Windows 11 build 22631
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate10 minpublished updated Maks Verny