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.
What You'll Learn
By the end of this lesson you will build a real form that refuses bad input: an index.html with a
<form>, a submit handler that stops the page reloading, and error messages written into the page where
a screen reader will announce them. Alongside it you will write the validation logic underneath — small
functions that take a value and return an error message or null — and prove them with asserts. You
arrive able to select elements, build them, and handle events (10-dom-and-events); you leave able to put
a gate in front of your data.
This lesson is required for the final project. The Task Manager will not accept an empty or over-long
task title — it shows a message in #form-error instead — and that check is the one you build here. It is
the last piece of the renderer milestone, and you assemble it for real in 15-capstone-project.
How this lesson runs
This lesson has code in two kinds of places, and the difference is not cosmetic:
- The form itself runs in your browser. Forms, inputs,
validityobjects and thesubmitevent do not exist in the in-page runner — it is a headless Node sandbox with no browser around it. So the real form is a singleindex.htmlfile you save and open yourself, in Your First Real Form below, and the DOM snippets around it are plain reference code markedno-run. - The validation logic runs right here. The functions that decide whether a value is acceptable are
ordinary JavaScript with no page involved, so those blocks are runnable
playgroundblocks and their output is real. Where a block models a browser check with a plain function rather than calling the browser's, it says so.
One boundary worth drawing before you start: the Constraint Validation API — required, pattern,
validity, checkValidity() — is a Web API. (It comes from the browser and other runtimes, not from
the JavaScript language itself.) That is why it needs a real browser to demonstrate, and why the logic you
write around it is the part that transfers everywhere.
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, and the browser enforces them for you.
Here is the model to hold on to, because everything else in this lesson hangs off it. The browser runs
constraint validation at two moments: whenever you ask, by calling checkValidity() on an input or on
the form, and automatically when the form is submitted. Submission is the important one — if any control
is invalid, the browser cancels the submit entirely, so your submit handler never runs, and it shows
its own message on the first invalid control.
Each input carries a validity object recording why it is invalid, one boolean per constraint, and
those names are worth knowing because they are what let you write your own message per failure:
| Property | True when |
|---|---|
valueMissing | the control has required and is empty |
tooShort / tooLong | the value violates minlength / maxlength — and the user typed it (see below) |
patternMismatch | the value does not match the pattern attribute |
typeMismatch | the value is not valid for the input's type (an email without an @) |
valid | every constraint passes — the summary the others feed |
One of those has a condition that surprises people and is worth stating exactly, because it decides
whether your own check is redundant or load-bearing. tooShort is true only when the value was last
changed by a user edit and is not the empty string — the standard's wording is that the element must
have its dirty value flag set and its "value was last changed by a user edit (as opposed to a change made
by a script)". So a too-short value your JavaScript assigned to input.value does not report
tooShort, and an empty required field reports valueMissing rather than tooShort. maxlength carries
the same condition, and additionally the browser simply stops the user typing past the limit, so
tooLong is rare in practice. The practical consequence: minlength is a good guard against what a user
types and a bad one against what your code sets, which is one concrete reason server-side validation is
not optional.
setCustomValidity(message) is how your own rules join that system. Call it with a non-empty string and
the control becomes invalid — validity.customError turns true, validity.valid turns false, and
checkValidity() returns false — until you call setCustomValidity("") to clear it. That is the whole
interaction: your custom rules do not run alongside the browser's, they feed into the same verdict.
With that model you can predict checkValidity() for cases this lesson never shows you. An input with
required and minlength="3" into which the user has typed ab reports valueMissing: false,
tooShort: true, valid: false, so checkValidity() is false. Leave that same field empty and it is
valueMissing: true with tooShort: false — a different reason, which is exactly why you branch on the
reason rather than just on valid. Set input.value = "ab" from a script instead of typing it and
tooShort is false, because the value was not last changed by a user edit. And in any of these cases, if
you had also called setCustomValidity("taken"), fixing the length alone would not make the control
valid, because the custom error counts too until you clear it with setCustomValidity("").
Your First Real Form
Save this as index.html and open it in your browser. Everything above is in it, and it is the same shape
the capstone's form takes.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>New Task</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 30rem; margin: 2rem auto; padding: 0 1rem; }
label { display: block; margin-bottom: 0.25rem; font-weight: 600; }
input { width: 100%; padding: 0.4rem; }
#form-error { color: #b00020; min-height: 1.4em; margin: 0.5rem 0; }
input:invalid { outline: 2px solid #b00020; }
</style>
</head>
<body>
<h1>New Task</h1>
<!-- novalidate turns OFF the browser's own error bubbles so we can show our
own messages. The validity objects still work exactly the same. -->
<form id="new-task-form" novalidate>
<label for="new-task-title">Title</label>
<input
id="new-task-title"
name="title"
type="text"
required
minlength="3"
maxlength="50"
aria-describedby="form-error"
autocomplete="off"
/>
<!-- aria-live="polite" makes a screen reader announce whatever we write
here, without interrupting what the user is currently doing. -->
<p id="form-error" role="status" aria-live="polite"></p>
<button type="submit">Add task</button>
</form>
<ul id="task-list"></ul>
<script>
const form = document.querySelector("#new-task-form");
const input = document.querySelector("#new-task-title");
const errorBox = document.querySelector("#form-error");
const list = document.querySelector("#task-list");
// Our own message per failure reason, read off the validity object.
function messageFor(field) {
if (field.validity.valueMissing) return "A title is required.";
if (field.validity.tooShort) return "Title must be at least 3 characters.";
if (field.validity.tooLong) return "Title must be 50 characters or fewer.";
return "";
}
function showError(message) {
// textContent, never innerHTML: this text can contain user input.
errorBox.textContent = message;
input.setAttribute("aria-invalid", message === "" ? "false" : "true");
}
form.addEventListener("submit", (event) => {
event.preventDefault(); // without this the page reloads and your code is lost
if (!input.checkValidity()) {
showError(messageFor(input));
input.focus();
return;
}
showError("");
const li = document.createElement("li");
li.textContent = input.value.trim();
list.appendChild(li);
input.value = "";
input.focus();
});
// Once a field has been submitted and failed, keep it honest as they type.
input.addEventListener("input", () => {
if (errorBox.textContent !== "") showError(messageFor(input));
});
</script>
</body>
</html>
What you should see. Open the page and press Add task with the box empty: nothing is added, and the
red line under the field reads A title is required. Type ab and the message changes, as you type, to
Title must be at least 3 characters. — the tooShort case, and it reports as such precisely because you
typed the value rather than a script setting it. Type a third character and the message clears. Press Add task
and the title appears in the list below, the box empties, and the cursor returns to it. The page never
reloads — that is preventDefault doing its job. If you use a screen reader, each message is announced
when it appears, because #form-error is an aria-live region and the input points at it with
aria-describedby.
Two experiments worth a minute each. Delete novalidate from the <form> tag and submit an empty form:
the browser shows its own bubble instead, and your handler never runs at all — that is submission
cancelling on an invalid control. Then put novalidate back and add input.setCustomValidity("Taken")
just after the showError("") line: now a perfectly valid title stops passing, because a custom error
counts against validity.valid like any built-in constraint.
The rest of this lesson is the logic side — the functions that decide what counts as valid, which you can run and test right here on the page.
The regex you need for this lesson
From here on, several rules are expressed as regular expressions — patterns written between slashes,
like /^\d{3}$/, that describe the shape a string must have. You call one with .test(value), which
answers a single question with true or false: does this value match the pattern? That is the method
almost every rule below uses. One other appears late in the lesson — .replace(pattern, ""), which
returns a new string with the matched parts swapped out (strings are immutable, so it never edits the
original) — and it is explained at the end of this primer.
You do not need the whole language of patterns here — only six pieces, and each one does exactly one job:
^and$are anchors.^means "the start of the string" and$means "the end". Together they force the whole value to match./[0-9]/is true for"x7"because it only asks whether a digit appears somewhere;/^[0-9]$/is false for"x7", because the string must be a digit start to finish. Validation almost always wants the anchored form — a password check that accepts"x7"as a valid four-digit PIN is the classic bug this causes.[…]is a character class: any ONE character from the set.[abc]matches a singlea,borc;[a-zA-Z0-9_]matches one letter, digit or underscore. It matches one character, not a run of them — that is what the counts below are for.[^…]is a negated class: any ONE character NOT in the set.[^\s@]means "one character that is neither whitespace nor an@". The^inside the brackets means "not"; the^outside means "start of string". Same symbol, two unrelated jobs, and mixing them up is the most common beginner error.\dis shorthand for[0-9], one digit. (\sis its whitespace equivalent, one space, tab or newline.)+means "one or more of the thing immediately before it".\d+is a run of digits of any length;[^\s@]+is a run of one or more non-space, non-@characters. Without it a pattern matches exactly one character.{n}and{n,m}are exact counts.\d{16}is exactly sixteen digits — a card number — and\d{3,4}is three or four, which is why it fits both a CVV and an Amex code.
Two more pieces of punctuation, both about escaping. A bare . in a pattern means "any character at
all", so a literal dot has to be escaped as \. — that is why the email pattern below reads \.
where you would expect a plain period. And because the slashes are what delimit the pattern, a literal
forward slash has to be escaped too, as \/: the expiry-date check later in this lesson reads
/^\d{2}\/\d{2}$/, which is "two digits, a literal slash, two digits". The rule behind both is the same
one — a backslash strips a character of its special meaning and leaves you the plain character — and it is
worth learning as one rule rather than two cases.
Read the email pattern used in this lesson with those six pieces in hand:
/^[^\s@]+@[^\s@]+\.[^\s@]+$/ says — from start to end of the string — one or more non-space non-@
characters, then a literal @, then more of the same, then a literal dot, then more of the same. That is
"something, an at-sign, something, a dot, something", which is a deliberately loose test: it accepts
plenty of addresses that no mail server would deliver to. That looseness is intentional, and the reason
is spelled out later in this lesson.
Run this to watch each piece decide on its own. Every line prints two booleans: a value that matches, then one that does not.
// Each line prints what one regex feature does. Read the pattern, predict the
// two booleans, then check.
// .test(value) asks one question and answers true or false.
console.log("[A-Z] anywhere:", /[A-Z]/.test("abcD"), /[A-Z]/.test("abcd"));
// ^ and $ anchor the pattern to the START and END of the whole string,
// so the WHOLE value must match, not just some part of it.
console.log("^[0-9]$ whole string:", /^[0-9]$/.test("7"), /^[0-9]$/.test("x7"));
// [...] is a character class: any ONE character from the set.
console.log("[abc] one of a,b,c:", /^[abc]$/.test("b"), /^[abc]$/.test("z"));
// [^...] is a NEGATED class: any one character NOT in the set.
console.log("[^0-9] not a digit:", /^[^0-9]$/.test("q"), /^[^0-9]$/.test("4"));
// \d is shorthand for [0-9]. \s is any whitespace character.
console.log("\\d one digit:", /^\d$/.test("4"), /^\d$/.test("q"));
console.log("\\s whitespace:", /\s/.test("a b"), /\s/.test("ab"));
// + means "one or more of the thing before it". * would allow zero.
console.log("^\\d+$ one or more:", /^\d+$/.test("903"), /^\d+$/.test(""));
// {n} means exactly n; {n,m} means between n and m.
console.log("^\\d{3}$ exactly 3:", /^\d{3}$/.test("123"), /^\d{3}$/.test("12"));
console.log("^\\d{3,4}$ 3 or 4:", /^\d{3,4}$/.test("1234"), /^\d{3,4}$/.test("12345"));
// A dot means "any character", so a LITERAL dot must be escaped as \.
console.log("\\. literal dot:", /^a\.b$/.test("a.b"), /^a\.b$/.test("axb"));
// A forward slash ends the pattern, so a literal slash is escaped as \/ too.
console.log("\\/ literal slash:", /^\d{2}\/\d{2}$/.test("07/26"), /^\d{2}\/\d{2}$/.test("0726"));
// Put them together: the email shape this lesson uses.
const email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
console.log("email shape:", email.test("user@example.com"), email.test("user@example"));
// .replace(pattern, "") is the OTHER regex method this lesson uses. Without the
// g flag it changes the FIRST match only; with g it changes every match.
console.log("replace no g:", JSON.stringify("4111 1111 1111".replace(/\s/, "")));
console.log("replace with g:", JSON.stringify("4111 1111 1111".replace(/\s/g, "")));
Every .test line prints true false — a value that matches, then one that does not. The last two lines
are the ones to look at twice: "4111 1111 1111".replace(/\s/, "") gives "41111111 1111", removing only
the first space, while .replace(/\s/g, "") gives "411111111111", removing them all. That trailing
g is the global flag, and it is the difference between stripping one space and stripping every one;
the card-number check later in this lesson depends on it, because a card typed as four groups has three
spaces in it and only the g form removes all three.
That is the whole regex vocabulary this lesson needs: anchors, character classes and negated classes,
\d and \s, +, {n} and {n,m}, the escapes \. and \/, the g flag, and the two methods
.test and .replace. Every pattern you meet in the rest of this lesson is built from exactly those.
Regular expressions go a great deal further than this — groups, alternation, quantifier greediness, capturing and replacing — and they get a lesson of their own: Regular Expressions, later in the track. Treat what is above as the working subset, not the subject.
// A plain-function model of the same rule checking, so it runs on this page.
// The browser does this off the HTML attributes; here we do it explicitly, so
// the LOGIC is visible and testable without a browser.
// 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"));
One question this section deliberately leaves open: validating on every input event means the rule runs
on every keystroke, which is free for a length check and not free for a rule that has to ask a server
whether a username is taken. The fix has a name — debouncing, waiting until typing pauses before
running the check — and it is a timing technique rather than a validation one, so it is covered in
14-performance along with its sibling, throttling. Recognize the shape now: a check cheap enough to run
per keystroke should; anything else should wait for the pause.
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",
});
Ordering the submit path
The whole-form check above reads as one function, but its body is a sequence with exactly one workable order. Here is the setting for the block below:
const log = [];
function handleSubmit(fields) {
// the five lines of the block below go here
return true;
}
const minThree = (v) => (v.length < 3 ? "too short" : null);
const required = (v) => (v.trim() === "" ? "required" : null);
// handleSubmit with a bad title and an empty note -> false, and log records
// "rejected -> title: too short; note: required"
// handleSubmit with both fields valid -> true, and log records "submitted 2 fields"
Arrange the code
Reassemble the body of handleSubmit. It must check every field before deciding anything, build one summary of the failures, reject with that summary when there are any, and otherwise record a successful submit. Why must these five lines run in this order and no other — and which order would still run, but let a form through with a failing field?
const results = fields.map((f) => ({ name: f.name, error: f.rule(f.value) }));if (summary !== "") { log.push("rejected -> " + summary); return false; }log.push("submitted " + results.length + " fields");const summary = failed.map((r) => r.name + ": " + r.error).join("; ");const failed = results.filter((r) => r.error !== null);
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();
}
Recall
Without scrolling up, and thinking back to 10-dom-and-events: your form's submit handler runs, finds the title invalid, and needs to show a message instead of sending anything. Two habits from that lesson are doing the work in the scaffold above. Which pair, and what does each one prevent?
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 "). Both are six characters of raw input. Write down each return — an error message ornull— before running. (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 write down exactly which keys the returned object holds before running: neither, one, or both. (Output-changing: you are predicting exactly which keys the returned object holds.)
After you have written your prediction down, check it against what actually happens.
What you should have seen
- The first trims to
"ab"and still returns"Title must be at least 3 characters."; the second trims to"abc"and passes withnull. Padding does not buy length, because the trim happens before the count. - One key:
{ dueDate: "Due date must be in the future." }. The title passes, so it is omitted entirely rather than mapped tonull. An empty object would mean the whole task is valid, which this one is not — which is why "no keys" is the signal for "valid" and any key at all means "rejected".
Transfer
The mechanism here is: at the point where untrusted data enters, check it against a rule and reject it with a message specific enough to act on — rather than accepting it and failing later somewhere less informative. A server receiving your form's JSON body faces the same moment with the same data. Which statement carries the invariant across correctly, naming what stays the same and what changes?
Capstone milestone
Milestone — the renderer's gate. The capstone's Task Manager refuses an empty or over-long title and shows a message in #form-error instead of adding anything. That is this lesson's form, and it is the last piece of the renderer milestone. Confirm you built it for real, in a browser.
Hint: You do not need the full capstone yet — this confirms the reject-with-an-accessible-message skill the renderer milestone is built on. In the capstone, this exact gate sits in front of new Task(title), whose own validation from 09-error-handling is the second line of defence.
- Built a real index.html with a form element, a required input, and a #form-error element, and opened it in a browser
- The submit handler calls event.preventDefault(), so an invalid form never reloads the page
- An empty or too-short title shows a specific message in #form-error and adds nothing to the list
- The message is written with textContent into an aria-live region the input points at with aria-describedby
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