Handling a form in JavaScript is really two jobs: get the values out, and decide whether they are acceptable. The browser gives you a proper API for both — FormData for reading, and constraint validation for checking — and using them together is what separates a form that guides someone from one that silently accepts nonsense and fails later on the server.
How do you read the values out of a form?
There are three ways, and they suit different situations. You can reach for a single control by id and read its value, which is fine when you know exactly which field you want. You can walk the form.elements collection, which gives you every control by name in one place. Or you can build a FormData object, which produces exactly the set of values the browser would send if the form were submitted normally.
That last option is the one to reach for by default. FormData understands the fiddly parts of form semantics for you: a checkbox that is not ticked is left out entirely, a radio group contributes only its checked member, and a disabled control is skipped. Hand-rolling those rules is how you end up submitting "on" for checkboxes that were never touched.
<form id="order" novalidate>
<label for="customer">Name</label>
<input id="customer" name="customer" type="text" required>
<label for="qty">Quantity</label>
<input id="qty" name="qty" type="number" min="1" max="10" value="2">
<label><input type="checkbox" name="gift" value="yes"> Gift wrap</label>
<label><input type="checkbox" name="gift" value="card"> Add a card</label>
<label><input type="radio" name="plan" value="basic" checked> Basic</label>
<label><input type="radio" name="plan" value="pro"> Pro</label>
<label for="size">Size</label>
<select id="size" name="size" multiple>
<option value="s">Small</option>
<option value="m" selected>Medium</option>
<option value="l" selected>Large</option>
</select>
<p id="customer-error" class="error"></p>
<button type="submit">Place order</button>
</form>
const form = document.querySelector("#order");
const data = new FormData(form);
console.log(data.get("customer")); // "" — the field is empty
console.log(data.get("qty")); // "2"
console.log(data.get("plan")); // "basic" — the checked radio
console.log(data.get("gift")); // null — no checkbox of that name is ticked
console.log(data.getAll("size")); // ["m", "l"] — both selected options
console.log(data.has("customer")); // true
console.log([...data.keys()]); // ["customer","qty","plan","size","size"]
Two things to notice. Every value comes back as a string, because that is what a form submission contains. And the key list has size twice, once for each selected option, which is the correct behaviour for a multi-select.
How do checkbox and radio groups that share a name behave?
Controls with the same name form a group, and the way you read a group depends on how many answers are possible. get returns only the first match, which is all you want for a radio group and a mistake for a checkbox group. getAll returns every match as an array, which is the correct call whenever more than one box can be ticked.
const form = document.querySelector("#order");
// Tick both gift options
form.querySelector('[name="gift"][value="yes"]').checked = true;
form.querySelector('[name="gift"][value="card"]').checked = true;
const data = new FormData(form);
console.log(data.getAll("gift")); // ["yes", "card"]
console.log(data.get("gift")); // "yes" — get only returns the first
// Radio buttons are mutually exclusive, so only the checked one appears
form.querySelector('[name="plan"][value="pro"]').checked = true;
console.log(new FormData(form).getAll("plan")); // ["pro"]
One shortcut needs a warning label. Object.fromEntries(new FormData(form)) turns the form into a plain object in one line, which is beautifully convenient — but when two controls share a name, the later one silently overwrites the earlier one. For the gift group above, the resulting object would contain only gift: "card". Reach for getAll whenever names are repeated, and check the shape of your data before trusting it.
How do I read a number input without getting a string?
Every DOM value is a string, including the value of type="number". The dedicated property for numeric input is valueAsNumber, which returns a real number when the field holds a valid number and NaN when it does not — including when the field is empty. That distinction matters: an empty quantity is not zero, and treating it as zero is how orders ship the wrong number of items.
const qty = document.querySelector("#qty");
console.log(qty.value); // "2" — the DOM always gives you a string
console.log(qty.valueAsNumber); // 2
console.log(typeof qty.valueAsNumber); // "number"
qty.value = "";
console.log(qty.value); // ""
console.log(qty.valueAsNumber); // NaN — an empty field is not zero
The same idea applies to checkboxes. checkbox.checked gives you a boolean, while checkbox.value gives the string in the value attribute, which is often meaningless to a person. Read checked for the answer and value for the detail.
How does the browser's built-in constraint validation work?
Modern browsers validate forms natively. Attributes like required, min, max, step, pattern and the type of the input itself (email, url, number) all express a rule, and the browser checks them before the form is allowed to submit.
Two APIs let you run those checks yourself. form.checkValidity() returns a boolean for the whole form. Each control also has a validity object with one flag per possible failure — valueMissing, typeMismatch, rangeOverflow, rangeUnderflow, patternMismatch, tooShort, tooLong — plus valid and customError. The validationMessage property holds the browser's own wording for the problem.
const form = document.querySelector("#order");
const customer = document.querySelector("#customer");
const qty = document.querySelector("#qty");
console.log(customer.validity.valueMissing); // true — a required field is empty
console.log(form.checkValidity()); // false
customer.value = "Priya";
console.log(customer.validity.valueMissing); // false
console.log(form.checkValidity()); // true
qty.value = "99";
console.log(qty.validity.rangeOverflow); // true — 99 is past max="10"
console.log(qty.validity.rangeUnderflow); // false
console.log(form.checkValidity()); // false
Reading the individual flags is more useful than the boolean, because it tells you which rule failed. That is what you need to put the right message next to the right field.
How do I add a validation rule the browser does not know about?
Browser rules cover format and range, not business rules. "The customer name must include a surname" or "this discount code is only valid on Tuesdays" need setCustomValidity. Give it a message and the control becomes invalid; give it an empty string and the custom message is cleared. The customError flag tells you whether a custom message is currently in force.
const form = document.querySelector("#order");
const customer = document.querySelector("#customer");
customer.value = "Priya";
// A rule the browser has no built-in opinion about
customer.setCustomValidity("Customer name must include a surname");
console.log(customer.validationMessage); // "Customer name must include a surname"
console.log(customer.validity.customError); // true
console.log(form.checkValidity()); // false
// An empty string clears the custom message
customer.setCustomValidity("");
console.log(customer.validity.customError); // false
console.log(form.checkValidity()); // true
Two practical rules. Clear the custom message as soon as the person edits the field, otherwise a stale error sticks around after they have fixed the problem — listening for the input event is the usual way. And remember what client-side validation actually is: a convenience that saves a person a pointless round trip. It is not a security control. The server must validate every value again, because anyone can bypass a check that only runs in the browser.
How should a validation error be shown to the person filling in the form?
Putting red text next to a field is only half the job. A screen reader user needs to be told that the field is invalid and what the message is, and that means two attributes: aria-invalid="true" on the control, and aria-describedby pointing at the element that holds the message. The message element itself is often given role="alert" so it is announced when it appears.
Moving focus to the first invalid control is the other half, because a keyboard user who submits an invalid form otherwise has no idea where the problem is.
const form = document.querySelector("#order");
const customer = document.querySelector("#customer");
const error = document.querySelector("#customer-error");
function showError(message) {
error.textContent = message;
customer.setCustomValidity(message);
customer.setAttribute("aria-invalid", message ? "true" : "false");
}
// Re-check while the person is typing so the error clears itself
customer.addEventListener("input", () => {
showError(customer.value.trim() ? "" : "Please enter a customer name");
});
form.addEventListener("submit", (event) => {
event.preventDefault();
showError(customer.value.trim() ? "" : "Please enter a customer name");
if (!form.checkValidity()) {
customer.focus();
return;
}
const data = new FormData(form);
console.log("customer:", data.get("customer"));
console.log("qty:", data.get("qty"));
console.log("plan:", data.get("plan"));
});
form.dispatchEvent(new Event("submit", { cancelable: true }));
console.log(error.textContent); // "Please enter a customer name"
console.log(customer.getAttribute("aria-invalid")); // "true"
customer.value = "Priya";
form.dispatchEvent(new Event("submit", { cancelable: true }));
console.log(error.textContent); // ""
console.log(customer.getAttribute("aria-invalid")); // "false"
The example form carries novalidate, which turns off the browser's own blocking bubble so your custom messages can take over. If you would rather keep the native bubbles, call reportValidity() instead of checkValidity() — it both checks and shows the browser's own message on the offending control.
The form handling reference
new FormData(form)— the values the browser would submit.data.get(name),data.getAll(name),data.has(name),[...data.entries()]— read one value, many values, or the whole set.input.valueAsNumber,input.checked— typed and boolean state instead of strings.form.checkValidity(),element.validity.valueMissingand friends — what the browser thinks of the data.element.setCustomValidity(message)— add a rule of your own; pass an empty string to clear it.aria-invalid,aria-describedby,role="alert"— make the error visible to assistive technology.
Try it yourself: write a form with a text field for a username, a number field for an age with min="18", and a checkbox for "accept terms" with required. Attach a submit handler that calls preventDefault(), then console.log the result of form.checkValidity() and each control's validity flags. Leave the form empty, submit it, and note which flags are set. Then type a valid age and tick the box and watch checkValidity() flip to true. Finish by adding a setCustomValidity rule that rejects usernames containing a space.