JAVASCRIPT / THE BROWSER: DOM, EVENTS, AND STORAGE
Reading and validating form input
Read values out of form controls as the types you actually need, then validate them with your own rules and the browser's validity API.
What you will learn
- value is always a string; use valueAsNumber, valueAsDate or checked for typed reads
- Trim text before testing for empty, since required accepts a field full of spaces
- Read validity flags like valueMissing and rangeOverflow instead of rewriting them
- Set app-level errors with setCustomValidity and clear them with an empty string
Understanding Reading and validating form input
Every form control stores what the user typed as text, so input.value is a string no matter which type you set: an empty number box gives '', not 0, and '9' < '10' is false because both sides are still strings compared character by character. That is why inputs expose typed views onto the same data, valueAsNumber for number and range, valueAsDate for date and time, and those views give NaN or null when there is nothing parseable in the box. Checkboxes and radios differ again: value is the payload that would be sent, while checked is the state you actually want, so if (box.value) is true whether or not the box is ticked.
Validation sits in two layers that you can mix freely. The browser already understands required, min, max, step, maxlength, type and pattern, and reports the result through a live validity object whose flags, such as valueMissing, typeMismatch, rangeOverflow and patternMismatch, name the exact rule that failed, while checkValidity() returns the overall verdict and fires an invalid event. Your own layer covers what markup cannot express: cross-field rules like a repeated password, business rules like a coupon length, or anything needing a server. Asking validity first keeps each rule in one place and lets the browser block submission even before your script has run.
The reading pattern is to intercept the submit event, call preventDefault() so the page does not navigate away and wipe out the messages you are about to draw, pull every field into a plain object, and then check that object. form.elements.fieldName reads one control by its name attribute, and new FormData(form) collects the whole form exactly as it would be sent, which is why unchecked checkboxes and disabled or unnamed controls are simply absent and data.get('news') comes back null rather than false. Whichever route you take, normalise before you judge, because ' ' is not empty until you trim it and '42' is not a number until you convert it.
const form = document.createElement('form');
form.innerHTML =
'<input name="username">' +
'<input name="age" type="number">' +
'<input name="terms" type="checkbox">';
document.body.append(form);
function readForm(f) {
return {
username: f.elements.username.value.trim(),
age: f.elements.age.valueAsNumber,
accepted: f.elements.terms.checked
};
}
function validate(d) {
const errors = [];
if (d.username === '') errors.push('username: required');
else if (d.username.length < 2) errors.push('username: too short');
if (Number.isNaN(d.age)) errors.push('age: enter a number');
else if (d.age < 18) errors.push('age: must be 18 or over');
if (!d.accepted) errors.push('terms: must be accepted');
return errors;
}
// Stand in for what a user typed.
form.elements.username.value = ' ';
form.elements.age.value = '17';
const data = readForm(form);
console.log(`raw ${JSON.stringify(form.elements.username.value)} -> trimmed ${JSON.stringify(data.username)}`);
console.log(`age ${data.age} is a ${typeof data.age}`);
console.log(`errors: ${validate(data).join(' | ')}`);
form.elements.username.value = 'Ada';
form.elements.age.value = '42';
form.elements.terms.checked = true;
console.log(`errors: ${validate(readForm(form)).length || 'none'}`);A control hands you a raw string plus a few state flags, and validation is the step that turns that into a trusted typed value and a message you can show.
Worked examples
Asking the browser what is wrong
Reads the live validity flags of an email input and adds an application-specific error on top of them.
const email = document.createElement('input');
email.type = 'email';
email.required = true;
document.body.append(email);
function report(label) {
const v = email.validity;
console.log(`${label} valid=${v.valid} missing=${v.valueMissing} typeMismatch=${v.typeMismatch} custom=${v.customError}`);
}
report('empty:');
email.value = 'ada.example.com';
report('no @:');
email.value = 'ada@example.com';
report('valid:');
email.setCustomValidity('That address is already registered');
report('taken:');
console.log(`message: ${email.validationMessage}`);
email.setCustomValidity('');
report('cleared:');Example explained
Line 1validity is a live object, so reading its flags after changing value re-runs the checks without calling checkValidity().
Line 2The empty field sets valueMissing but leaves typeMismatch false, because an empty value has no format to violate.
Line 3setCustomValidity stores your own message and raises customError, which drags valid down to false even though every built-in rule passes.
Line 4Passing an empty string is the only way to clear a custom error; a stale message keeps the control unsubmittable forever.
Collecting a whole form on submit
Shows what FormData does and does not include when it snapshots a form inside a submit handler.
const form = document.createElement('form');
form.innerHTML =
'<input name="city" value=" Oslo ">' +
'<input name="news" type="checkbox">' +
'<select name="plan"><option value="free">Free</option><option value="pro">Pro</option></select>';
document.body.append(form);
form.addEventListener('submit', event => {
event.preventDefault();
const data = new FormData(form);
console.log(`city ${JSON.stringify(data.get('city'))}`);
console.log(`news ${JSON.stringify(data.get('news'))}`);
console.log(`plan ${data.get('plan')}`);
console.log(`sent fields: ${[...data.keys()].join(', ')}`);
});
form.requestSubmit();Example explained
Line 1FormData copies the raw strings, spaces included, so ' Oslo ' still needs trimming after collection.
Line 2An unchecked checkbox is never submitted, so get('news') returns null rather than false, and the key is missing from keys().
Line 3The select contributes 'free' because the first option is selected by default, and it is the option's value attribute that travels, not its label.
Line 4requestSubmit() runs constraint validation and fires submit, while form.submit() would skip both and navigate; preventDefault is what keeps the page alive.
Typed reads: numbers and dates
Demonstrates value sanitization on a number input and the parsed views valueAsNumber and valueAsDate.
const qty = document.createElement('input');
Object.assign(qty, { type: 'number', min: '1', max: '10', value: '3' });
const due = document.createElement('input');
Object.assign(due, { type: 'date', value: '2026-03-01' });
document.body.append(qty, due);
console.log(`${typeof qty.value} ${JSON.stringify(qty.value)} -> ${qty.valueAsNumber + 1}`);
qty.value = 'twelve';
console.log(`after "twelve": ${JSON.stringify(qty.value)} ${qty.valueAsNumber}`);
qty.value = '99';
console.log(`rangeOverflow ${qty.validity.rangeOverflow}, max ${qty.max}`);
console.log(due.valueAsDate.toISOString());Example explained
Line 1value is the string '3' even here, so valueAsNumber is what gives 4; qty.value + 1 would have produced '31'.
Line 2Assigning 'twelve' is rejected by the number input's value sanitization: value becomes '' and valueAsNumber becomes NaN.
Line 3min and max never clamp anything: 99 stays in the box and only rangeOverflow flips to true, and qty.max is itself a string.
Line 4valueAsDate parses a date field as UTC midnight, which lets you compare Date objects instead of slicing '2026-03-01' apart.
Important notes
Client-side validation is a convenience for the user, not a security boundary; the same request can be sent with fetch or curl, so every rule must be enforced again on the server.
Letters typed into a number input leave value as '' and set validity.badInput, so garbage and an empty box look identical unless you inspect badInput.
Common mistakes
Comparing value with a number: form.elements.age.value === 18 is never true because the left side is the string '18', so the branch silently never runs.
Expecting required to reject blank space: a text input holding ' ' satisfies required and passes checkValidity(), so an empty-looking name is accepted and stored.
Testing a checkbox with if (box.value): value is 'on' whether or not the box is ticked, so the terms check passes without anyone agreeing to anything.
Try it yourself
Change, predict, then run
Build a form with a text field for a coupon code and a number field for quantity, and on submit log one message per broken rule: the trimmed, uppercased code must be exactly six characters, and the quantity must be a whole number from 1 to 5. Confirm that ' abc123 ' with a quantity of 7 produces exactly one message.
Open the JavaScript workspaceCheck your understanding
A required number input is left empty. Your script reads it with Number(input.value) and rejects the field only when the result is NaN. What happens?
- The field is rejected, because Number('') is NaN and the guard fires
- The field is rejected, because the required attribute makes the read fail
- The field is accepted as 0, because Number('') is 0 and the NaN guard never fires
- The field is accepted as '', because Number returns the original string for empty input
Show answer
Number('') coerces to 0, so the NaN check never triggers and the rest of your code sees a plausible-looking zero. The tempting option is that Number('') is NaN, which is true for 'twelve' but not for an empty string; valueAsNumber is the read that actually gives NaN for an empty numeric field, and required only blocks the browser's own submission path, not your manual read.