Form Validation
Every form on the web is a contract between you and the user. They fill in data, you process it. But users make mistakes — typos, wrong formats, skipped required fields. Without validation, bad data reaches your server, causes errors, or creates security issues.
Client-side form validation catches problems early, right in the browser, before a network request is ever made. It gives users immediate feedback and a smoother experience. It is not a replacement for server-side validation — always validate on the server too — but it dramatically improves usability.
The real Constraint Validation API needs a browser; the runnable blocks here simulate it. There are no forms, inputs, or validity objects in the in-page runner — it is a headless Node sandbox — so every playground block below models the browser's checks with plain functions you can Run and read the real output from. The validation logic is the point, and it is the same logic either way; to see required, pattern, and checkValidity() do their own work, put the markup in an HTML file and open it in your browser.
The Basics: Constraint Validation API
Modern browsers ship with a built-in validation system. HTML attributes like required, minlength, maxlength, type="email", and pattern define constraints. The browser checks them automatically when a form is submitted.
JavaScript gives you access to this through the Constraint Validation API. Every input element has a validity object describing its current state, and a checkValidity() method that returns true or false.
// Simulating constraint validation checks
function checkField(value, rules) {
const errors = [];
if (rules.required && value.trim() === "") {
errors.push("This field is required.");
}
if (rules.minLength && value.length < rules.minLength) {
errors.push(`Must be at least ${rules.minLength} characters.`);
}
if (rules.maxLength && value.length > rules.maxLength) {
errors.push(`Must be no more than ${rules.maxLength} characters.`);
}
if (rules.pattern && !rules.pattern.test(value)) {
errors.push("Format is invalid.");
}
return errors;
}
const usernameErrors = checkField("al", {
required: true,
minLength: 3,
maxLength: 20,
pattern: /^[a-zA-Z0-9_]+$/,
});
console.log("Username errors:", usernameErrors);
const emailErrors = checkField("user@example.com", {
required: true,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
});
console.log("Email errors:", emailErrors);
Custom Validation Rules
Built-in constraints cover common cases, but real applications need more. Is this username already taken? Do the two password fields match? Is the date in the future? These require custom logic.
The pattern is straightforward: write a function that takes a value and returns an array of error messages. An empty array means the field is valid.
// Custom validators for common scenarios
const validators = {
email(value) {
const errors = [];
if (!value.trim()) {
errors.push("Email is required.");
return errors;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
errors.push("Enter a valid email address.");
}
return errors;
},
password(value) {
const errors = [];
if (value.length < 8) errors.push("Must be at least 8 characters.");
if (!/[A-Z]/.test(value)) errors.push("Must contain an uppercase letter.");
if (!/[0-9]/.test(value)) errors.push("Must contain a number.");
return errors;
},
confirmPassword(value, original) {
return value !== original ? ["Passwords do not match."] : [];
},
};
// Test the validators
const emailResult = validators.email("not-an-email");
console.log("Email validation:", emailResult);
const passwordResult = validators.password("weak");
console.log("Password validation:", passwordResult);
const matchResult = validators.confirmPassword("abc123XY", "abc123XY");
console.log("Passwords match:", matchResult.length === 0 ? "Yes" : matchResult);
Each validator returns an array of error messages, and an empty array means the field is valid — so the length of the returned array is really the field's validation state (0 = valid, more = the count of things wrong). Trace the password rules before you run the next block.
Predict
Using the password validator above (min 8 chars, one uppercase, one number), what does calling it with the value 'weak' return?
const validators = {
password(value) {
const errors = [];
if (value.length < 8) errors.push("Must be at least 8 characters.");
if (!/[A-Z]/.test(value)) errors.push("Must contain an uppercase letter.");
if (!/[0-9]/.test(value)) errors.push("Must contain a number.");
return errors;
},
};
console.log(validators.password("weak"));Real-Time Feedback
Waiting until submit to show errors is frustrating. Users fill out a long form, hit submit, and suddenly see five error messages. A better approach is to validate fields as the user interacts with them — typically on the blur event (when they leave a field) or input event (as they type).
// Real-time validation simulation
function createFieldValidator(rules) {
return {
touched: false,
value: "",
onBlur(newValue) {
this.touched = true;
this.value = newValue;
return this.validate();
},
onInput(newValue) {
this.value = newValue;
if (this.touched) {
return this.validate();
}
return [];
},
validate() {
const errors = [];
for (const rule of rules) {
const result = rule(this.value);
if (result) errors.push(result);
}
return errors;
},
};
}
const required = (v) => v.trim() === "" ? "This field is required." : null;
const minLen = (n) => (v) => v.length < n ? `Minimum ${n} characters.` : null;
const isEmail = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : "Invalid email format.";
const emailField = createFieldValidator([required, isEmail]);
const nameField = createFieldValidator([required, minLen(2)]);
// Simulate user interactions
console.log("--- Name field ---");
console.log("Input 'J':", nameField.onInput("J")); // Not touched yet, no error shown
console.log("Blur with 'J':", nameField.onBlur("J")); // Now touched, shows error
console.log("Input 'Jane':", nameField.onInput("Jane")); // Touched, shows valid
console.log("\n--- Email field ---");
console.log("Blur empty:", emailField.onBlur(""));
console.log("Input bad:", emailField.onInput("notanemail"));
console.log("Input good:", emailField.onInput("jane@example.com"));
The most dangerous validation bug is the one that reports success on bad input — the form looks validated, but nothing was actually checked. Once each field has been checked individually, a form has to combine those results into one go/no-go decision, and that combining step is easy to get subtly wrong. The block below has exactly that flaw: it enables submission while two fields are still invalid.
Debug
Each field has already been validated; its ok flag is true only when that field passed. isFormReady should return true ONLY when every field passed — but here two fields failed and it still reports the form is ready to submit. Commit a hypothesis about why, then fix it so the form is ready only when all fields pass.
const assert = require('assert');
// Each field has already been checked; ok is true when that field passed.
const fields = [
{ name: "email", ok: false }, // failed: bad address
{ name: "password", ok: true }, // passed
{ name: "terms", ok: false }, // failed: box unchecked
];
// The submit button should enable ONLY when every field passed.
function isFormReady(fields) {
return fields.some((field) => field.ok);
}
// Two fields are invalid, so this must be false — but it isn't.
const ready = isFormReady(fields);
assert.strictEqual(ready, false, 'two fields failed, so ready must be false, got ' + ready);
console.log("Form ready?", ready);Expected output: Form ready? false
Building a Complete Validation System
Individual field validators are useful, but a form has multiple fields. You need to validate all of them together when the user submits, collect all the errors, and decide whether to proceed.
// A complete form validation system
function validateForm(fields) {
const results = {};
let isValid = true;
for (const [name, { value, validators }] of Object.entries(fields)) {
const errors = validators.flatMap((fn) => {
const result = fn(value);
return result ? [result] : [];
});
results[name] = { value, errors, valid: errors.length === 0 };
if (errors.length > 0) isValid = false;
}
return { isValid, fields: results };
}
// Reusable validator functions
const required = (msg = "Required.") => (v) => v.trim() ? null : msg;
const email = () => (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? null : "Invalid email.";
const minLength = (n) => (v) => v.length >= n ? null : `At least ${n} characters required.`;
const matches = (other, msg) => (v) => v === other ? null : msg;
// Simulate a registration form submission
function handleSignup(formData) {
const result = validateForm({
name: {
value: formData.name,
validators: [required("Name is required."), minLength(2)],
},
email: {
value: formData.email,
validators: [required("Email is required."), email()],
},
password: {
value: formData.password,
validators: [required("Password is required."), minLength(8)],
},
confirmPassword: {
value: formData.confirmPassword,
validators: [matches(formData.password, "Passwords do not match.")],
},
});
if (result.isValid) {
console.log("Form is valid! Submitting...");
console.log("Data:", { name: formData.name, email: formData.email });
} else {
console.log("Form has errors:");
for (const [field, data] of Object.entries(result.fields)) {
if (data.errors.length > 0) {
console.log(` ${field}: ${data.errors.join(", ")}`);
}
}
}
}
// Test with invalid data
handleSignup({
name: "A",
email: "not-an-email",
password: "short",
confirmPassword: "different",
});
console.log("\n---\n");
// Test with valid data
handleSignup({
name: "Alice",
email: "alice@example.com",
password: "securePass1",
confirmPassword: "securePass1",
});
Try It Yourself
Build a validator for a credit card form. It should check that the card number is 16 digits, the expiry is a future date in MM/YY format, and the CVV is 3 or 4 digits. Try submitting both valid and invalid data to see the results.
// Credit card form validator — experiment with different inputs
const validators = {
cardNumber(value) {
const stripped = value.replace(/\s/g, "");
if (!/^\d{16}$/.test(stripped)) {
return "Card number must be 16 digits.";
}
return null;
},
expiry(value) {
if (!/^\d{2}\/\d{2}$/.test(value)) {
return "Expiry must be in MM/YY format.";
}
const [month, year] = value.split("/").map(Number);
if (month < 1 || month > 12) return "Invalid month.";
const now = new Date();
const expDate = new Date(2000 + year, month - 1);
if (expDate <= now) return "Card has expired.";
return null;
},
cvv(value) {
if (!/^\d{3,4}$/.test(value)) {
return "CVV must be 3 or 4 digits.";
}
return null;
},
};
function validateCard(card) {
const errors = {};
for (const [field, validate] of Object.entries(validators)) {
const error = validate(card[field] ?? "");
if (error) errors[field] = error;
}
return {
valid: Object.keys(errors).length === 0,
errors,
};
}
// Try modifying these values
const testCases = [
{ cardNumber: "4111 1111 1111 1111", expiry: "12/27", cvv: "123" },
{ cardNumber: "1234", expiry: "13/22", cvv: "99" },
{ cardNumber: "4000000000001234", expiry: "01/26", cvv: "4321" },
];
for (const card of testCases) {
const result = validateCard(card);
console.log("Card:", card.cardNumber);
if (result.valid) {
console.log(" Valid!");
} else {
console.log(" Errors:", result.errors);
}
console.log();
}
Experimenting is one thing; proving your validators are correct is another. This is a build task: a small program that reports its own pass/fail. You are given two task submissions and three empty validators to finish. Run it as-is and it fails immediately, telling you which validator is still missing. Implement each one until every check passes and it prints All checks passed.
The three validators reuse exactly what this lesson taught: a field validator returns an error message string or null (from Real-Time Feedback and Custom Validation Rules), a future-date check adapts the expiry rule from the exploration above (kept deterministic by comparing the ISO date strings directly, since they sort chronologically), and an aggregator collects field errors into an object the way Building a Complete Validation System and the validateCard example do. One discipline is non-negotiable and is exactly why the checks are strict about it: a passing validator returns null, never the empty string "" — the two look interchangeable but are not, and a form that treats "" as "valid" is the silent false-pass this lesson keeps warning about.
Build
Finish the build. Three validators are stubbed out and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, decide what that validator is missing, then implement the three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down. A valid field must return null, not the empty string.
const assert = require('assert');
// A FIXED "today" so this program is deterministic — it never reads the real
// clock or the machine's timezone. Every date check compares against this.
const TODAY = "2026-07-14";
// Two task submissions to validate. Do NOT change these. Dates are assumed to
// be well-formed ISO "YYYY-MM-DD" strings — the fixed data above guarantees it.
const goodTask = { title: "Ship the renderer", dueDate: "2026-08-01" };
const badTask = { title: "Hi", dueDate: "2026-01-01" };
// TODO 1: return an error STRING if the title is invalid, or null if it is fine.
// Rules (after trimming): required (not blank), 3 to 50 characters.
// validateTitle("Hi") -> "Title must be at least 3 characters."
// validateTitle("Plan") -> null
function validateTitle(title) {
// your code here
}
// TODO 2: return an error STRING if the due date is not STRICTLY after today,
// or null if it is a valid future date. Compare against the fixed `today`,
// never the real clock. Both are "YYYY-MM-DD" strings — and because ISO date
// strings sort chronologically, a plain string comparison (dateString > today)
// IS a date comparison, no Date object needed. Note: today itself is NOT in
// the future, so a due date equal to today must be rejected.
// validateDueDate("2026-01-01", "2026-07-14") -> "Due date must be in the future."
// validateDueDate("2026-07-14", "2026-07-14") -> "Due date must be in the future."
// validateDueDate("2026-08-01", "2026-07-14") -> null
function validateDueDate(dateString, today) {
// your code here
}
// TODO 3: return an OBJECT mapping each invalid field to its error string.
// Skip fields that passed (their validator returned null). An empty object
// means the whole task is valid.
// validateForm(goodTask, TODAY) -> {}
// validateForm(badTask, TODAY) -> { title: "...", dueDate: "..." }
function validateForm(task, today) {
// your code here
}
// --- Build checks: these must all pass. Do not edit below this line. ---
assert.strictEqual(
validateTitle("Hi"),
"Title must be at least 3 characters.",
"validateTitle should return the length message for a too-short title",
);
assert.strictEqual(
validateTitle("Ship the renderer"),
null,
"validateTitle should return null (not the empty string) for a valid title",
);
assert.strictEqual(
validateDueDate("2026-01-01", TODAY),
"Due date must be in the future.",
"validateDueDate should reject a date that is not after today",
);
assert.strictEqual(
validateDueDate(TODAY, TODAY),
"Due date must be in the future.",
"validateDueDate should reject a due date equal to today (today is not the future)",
);
assert.strictEqual(
validateDueDate("2026-08-01", TODAY),
null,
"validateDueDate should return null (not the empty string) for a future date",
);
assert.deepStrictEqual(
validateForm(goodTask, TODAY),
{},
"validateForm should return an empty object when every field passes",
);
assert.deepStrictEqual(
validateForm(badTask, TODAY),
{
title: "Title must be at least 3 characters.",
dueDate: "Due date must be in the future.",
},
"validateForm should map each invalid field to its error string",
);
console.log("All checks passed.");
console.log("Good task valid?", Object.keys(validateForm(goodTask, TODAY)).length === 0);
console.log("Bad task invalid fields:", Object.keys(validateForm(badTask, TODAY)).join(", "));Expected output: All checks passed.
Good task valid? true
Bad task invalid fields: title, dueDate
Once it passes, try two variations and predict each before running:
- Trim happens before counting. Call
validateTitle(" ab ")and thenvalidateTitle(" abc "), and predict each return before running — the first is six characters of raw input but trims to just"ab", so it still returns"Title must be at least 3 characters.", while the second trims to"abc"and passes withnull. Padding does not buy length. (Output-changing: you are predicting each returned value, message vs.null.) - A half-valid task through the aggregator. Call
validateForm({ title: "Ship it now", dueDate: "2026-07-14" }, TODAY)and predict the object — the title passes so it is omitted, and only the failing field appears, giving{ dueDate: "Due date must be in the future." }, not{}and not both keys. An empty object would mean the whole task is valid, which this one is not. (Output-changing: you are predicting exactly which keys the returned object holds.)
Key Takeaways
- Client-side validation improves user experience but never replaces server-side validation — always validate on both sides.
- The Constraint Validation API provides built-in browser validation through HTML attributes and JavaScript methods like
checkValidity(). - Custom validators are plain functions: they take a value and return an error message or
null. This makes them easy to compose and reuse. - Wait for the first
blur(when a user leaves a field) before showing that field's errors, then keep revalidating oninput— atouchedflag gives you live feedback without shouting at someone mid-typing. - Check every field before displaying errors — never stop the whole form at the first field that fails. Within a single field you may still return just its first broken rule, or collect all of them; both are fine as long as no field goes unchecked.
- Keep validators pure and stateless. A validator should only care about the value it receives, not how it got there.
- For password confirmation and similar cross-field rules, pass the related value as an argument rather than reading global state.
Pro Tip: Structure your validators as small, single-purpose functions and compose them into arrays per field. This makes it trivial to reuse rules like
requiredorminLengthacross every form in your application — and to add new rules without touching existing logic.
Next Steps
That wraps up the browser module — you can now build, style, and validate real interactive pages. The next lesson moves into engineering practices, starting with modules and bundling: how to split your code across files, share functionality with imports and exports, and organize a growing codebase.
Next lesson
Modules and Bundling
Organize your JavaScript code with ES modules. Learn import/export syntax, default vs named exports, and dynamic imports for code splitting.
22 min