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

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

  1. Step 1.

    Read the attribute off every control on the page.

    node probe.mjs inventory
    
    name    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 false

    Three rows disagree with the markup. plan-b has no attribute and still reports valueMissing: one member covers the radio group. country reports false because its first option carries a non-empty value. csrf is type="hidden" and willValidate is false.

  2. Step 2.

    Click submit with every field untouched.

    node probe.mjs empty
    
    still 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. country is not among them, so a required select is the field nobody notices is unguarded. Focus landed on name, first in document order.

  3. Step 3.

    Type one space into the first three fields, then a word into the number.

    node probe.mjs space
    
    name    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 false

    A single space satisfies required on type="text" and fails on type="email" and type="number", which sanitize it to the empty string first. seventeen also empties the number field, with badInput set beside valueMissing.

  4. Step 4.

    Check the radio with no attribute, then the checkbox, then a country.

    node probe.mjs radio
    
    plan-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-b cleared valueMissing on plan-a too. The group is one constraint, satisfied by any member, including one the markup never marked.

  5. Step 5.

    Submit the second form, whose required field carries the hidden attribute.

    node probe.mjs hidden
    
    still 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() left document.activeElement on the button. Compare csrf in step 1: type="hidden" is exempt, a visible type hidden by CSS is not.

  6. Step 6.

    Type one space into the name, turn the form's validation off, and submit.

    node probe.mjs send
    
    landed 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 true

    Five keys reached the route and two did not. An unchecked radio group and checkbox are absent from the body, not empty in it. country sent none.

  7. 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 false

    Three 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.

  8. Step 8.

    Read back what the store kept.

    curl -s http://127.0.0.1:8931/rows
    
    row 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

Sign: A required select is signed off because the form refuses to submit while it is untouched.Cause: The refusal came from another field. Step 1 shows country reporting valueMissing false while five siblings report true, and step 2 leaves it out of the invalid list. A select is only missing when the selected option's value is the empty string, so a placeholder written as value=none is always filled.
Sign: Every radio in a group is given the attribute, or only one is, and the test is written against whichever has it.Cause: The constraint belongs to the group. Step 1 shows plan-b reporting valueMissing without the attribute, and step 4 shows checking plan-b clearing the flag on plan-a. An assertion on one member's required property describes the markup and not the behaviour.
Sign: A field holding one space passes the form and reaches the database as an empty-looking row.Cause: required is satisfied by any value of non-zero length on a text input, as step 3 measures. type=email and type=number sanitize first, so the same input fails there, which makes the behaviour look inconsistent across one form. Step 8 shows the two rows that survived.
Sign: A submit button does nothing, with no error visible on the page.Cause: A required control was hidden. Step 5 uses the hidden attribute, whose computed display is none. The control still validates, it still blocks, and Chrome cannot show its bubble on something it cannot focus. Step 5 prints the console line that is the only evidence: an invalid form control with name='promo' is not focusable.

What to check next

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.

intermediate10 minpublished updated Maks Verny