Lesson 23 / 25
Built-in Validation
The validity states constraint attributes produce, submission being stopped, and why the same rules need to be reapplied on the server.
Contents
The fields are named; next comes whether the value entered is accepted.
Before submission begins, the browser tests the fields inside the form. If a field violates a declared constraint, submission is stopped and a message is shown to the user. This mechanism is called constraint validation, and it is declared entirely in markup; it needs no script.
This lesson takes up both sides at once: the rules the browser applies, and why the same rules need to be reapplied on the server.
Constraint Attributes
Constraints are written on fields as attributes, and each is meaningful for certain types.
| Attribute | Constraint |
|---|---|
required |
The value cannot be empty |
min, max |
Lower and upper bound on numeric and date fields |
step |
The increments the value is allowed to move in |
minlength, maxlength |
Lower and upper bound on text length |
pattern |
The regular expression the text must match |
type |
The type’s own format rule (email, url) |
The pattern value is a regular expression and uses the concepts defined in the Regular
Expressions lesson in the Shell Programming course. There is one distinction: the pattern
applies to the entire value; anchors at the start and end are not needed, and writing them
changes nothing.
Validity States
When a constraint is violated, the field acquires a validity state. The names of the states are defined, and more than one can be true on a field at the same time.
// validation.mjs — the model of constraint validation rules const FIELDS = { station: { type: "text", required: true, maxlength: 20, pattern: "[a-z-]+" }, email: { type: "email", required: true }, temperature: { type: "number", required: true, min: -60, max: 60, step: 0.1 }, date: { type: "date", required: true }, }; const EMAIL = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; const DATE = /^\d{4}-\d{2}-\d{2}$/; function validate(name, value) { const rule = FIELDS[name]; const states = []; if (rule.required && value === "") states.push("valueMissing"); if (value === "") return states; if (rule.type === "email" && !EMAIL.test(value)) states.push("typeMismatch"); if (rule.type === "date" && !DATE.test(value)) states.push("typeMismatch"); if (rule.maxlength !== undefined && [...value].length > rule.maxlength) states.push("tooLong"); if (rule.pattern && !new RegExp("^(?:" + rule.pattern + ")$", "u").test(value)) { states.push("patternMismatch"); } if (rule.type === "number") { const num = Number(value); if (value.trim() === "" || Number.isNaN(num)) return [...states, "badInput"]; if (rule.min !== undefined && num < rule.min) states.push("rangeUnderflow"); if (rule.max !== undefined && num > rule.max) states.push("rangeOverflow"); if (rule.step) { const base = rule.min ?? 0; const steps = Math.round(((num - base) / rule.step) * 1e6) / 1e6; if (Math.abs(steps - Math.round(steps)) > 1e-9) states.push("stepMismatch"); } } return states; } const trials = [ ["station", "north-slope"], ["station", ""], ["station", "North Slope"], ["station", "a-very-long-station-name"], ["email", "[email protected]"], ["email", "observer(at)example"], ["temperature", "-4.2"], ["temperature", "-4.25"], ["temperature", "95"], ["temperature", "four"], ["date", "2024-03-12"], ["date", "12.03.2024"], ]; for (const [name, value] of trials) { const states = validate(name, value); console.log( name.padEnd(12) + JSON.stringify(value).padEnd(28) + (states.length === 0 ? "valid" : states.join(", ")), ); }
station "north-slope" valid station "" valueMissing station "North Slope" patternMismatch station "a-very-long-station-name" tooLong email "[email protected]" valid email "observer(at)example" typeMismatch temperature "-4.2" valid temperature "-4.25" stepMismatch temperature "95" rangeOverflow temperature "four" badInput date "2024-03-12" valid date "12.03.2024" typeMismatch
Four rows call for explanation.
stepMismatch shows how the step rule works. A step="0.1" declaration does not say “any
decimal is accepted”; it requires the value to fall on step multiples starting from a base
value. The base is min if it is written — here -60 — otherwise zero. -4.25 does not
fall on this grid.
badInput is a separate state from the others: the field could not even produce a value.
When non-numeric text is entered into a number field, the field’s value becomes the empty
string; even though the user typed something, the field appears empty. This state never
reaches the server.
The tooLong state is rarely seen in the browser, because maxlength also blocks typing past
the limit — the user cannot enter more than the bound. The state only arises when the user
pastes a value, or when the behavior layer writes the value in afterward; an initial value
given through the value attribute and never edited does not produce this state. This is an
example of the constraint acting not only before submission but during input too.
The last row shows the date field’s format rule: a local written form is not accepted, the value the field produces is in a defined format.
Submission Is Stopped
If a field is invalid, submission does not happen, and the browser focuses the first invalid field and shows a message. The message’s text is produced in the browser’s own language and cannot be changed from the document; its style and duration also depend on the browser.
This behavior has two limits. The message shows for only one field at a time; if a ten-field form has nine errors, the user discovers them one by one. The message can disappear quickly, and the user cannot bring it back.
Writing the novalidate attribute on the form turns this behavior off; submission happens
regardless of the constraints. The attribute makes sense in two cases: declaring the
constraints in the document while leaving the check to the server, or doing a custom check
without leaving the error presentation to the browser. The second is the next lesson’s topic.
Client Validation Is Not a Guarantee
Every rule the browser applies is applied on the client, and the client is under the user’s control. Removing the constraints needs no special tool: submission can be made without using the document at all. As shown in the Input Types lesson, all that reaches the server is name-value strings; which field they came from, and which constraints were written on that field, is not information the body carries.
The server below reapplies the same constraints and prints its reply to four different bodies. Two of these bodies were sent by violating the constraints written in the document.
// server-validation.mjs — the same constraints reapplied on the server import { createServer } from "node:http"; const FIELDS = { station: { required: true, maxlength: 20, pattern: "[a-z-]+" }, temperature: { required: true, numeric: true, min: -60, max: 60, step: 0.1 }, }; function validate(pairs) { const errors = []; for (const [name, rule] of Object.entries(FIELDS)) { const value = pairs.get(name) ?? ""; if (rule.required && value === "") { errors.push([name, "valueMissing"]); continue; } if (rule.maxlength && [...value].length > rule.maxlength) errors.push([name, "tooLong"]); if (rule.pattern && !new RegExp("^(?:" + rule.pattern + ")$", "u").test(value)) { errors.push([name, "patternMismatch"]); } if (rule.numeric) { const num = Number(value); if (Number.isNaN(num)) { errors.push([name, "badInput"]); continue; } if (num < rule.min) errors.push([name, "rangeUnderflow"]); if (num > rule.max) errors.push([name, "rangeOverflow"]); const steps = (num - rule.min) / rule.step; if (Math.abs(steps - Math.round(steps)) > 1e-9) errors.push([name, "stepMismatch"]); } } // Undeclared fields: names that should not be in the entry list. for (const name of new Set(pairs.keys())) { if (!(name in FIELDS)) errors.push([name, "unknownField"]); } return errors; } const server = createServer((request, response) => { const chunks = []; request.on("data", (chunk) => chunks.push(chunk)); request.on("end", () => { const pairs = new URLSearchParams(Buffer.concat(chunks).toString()); const errors = validate(pairs); const status = errors.length === 0 ? 201 : 400; response.writeHead(status, { "content-type": "text/plain; charset=utf-8" }); response.end(errors.map(([name, code]) => name + ": " + code).join("\n") || "record accepted"); }); }); server.listen(0, "127.0.0.1", async () => { const url = "http://127.0.0.1:" + server.address().port + "/record"; const bodies = [ "station=north-slope&temperature=-4.2", "station=North%20Slope&temperature=95", "temperature=-4.25", "station=north-slope&temperature=-4.2&admin=true", ]; for (const body of bodies) { const response = await fetch(url, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body, }); console.log("body :", body); console.log("status :", response.status); console.log("reply :", (await response.text()).replace(/\n/g, " | ")); console.log("---"); } server.close(); });
body : station=north-slope&temperature=-4.2 status : 201 reply : record accepted --- body : station=North%20Slope&temperature=95 status : 400 reply : station: patternMismatch | temperature: rangeOverflow --- body : temperature=-4.25 status : 400 reply : station: valueMissing | temperature: stepMismatch --- body : station=north-slope&temperature=-4.2&admin=true status : 400 reply : admin: unknownField ---
The third body never sends a field that is declared required in the document; the second
body violates two constraints at once. The browser would have blocked these submissions; when
they are made directly to the server, the only thing blocking them is the server’s own check.
The fourth body shows a separate kind of flaw. A field that does not exist in the form is sent, and the server rejects it. Rejecting unexpected fields instead of silently ignoring them prevents privilege escalation in implementations that turn the body directly into a record.
The status code of the reply also carries information: by the classification in the HTTP
Request and Response lesson, 400 announces that the request was malformed, 201 that a
resource was created.
A Rule Kept in Two Places
The client and server checks state the same rule twice, and this looks like a repetition. Their jobs are different.
Client validation is a convenience: it gives the user feedback without delay and keeps wasted requests from reaching the server. Its being removable is not a problem, because it is not there to protect anyone.
Server validation is a rule: it is the only place that ensures the data’s consistency. It applies regardless of what was written on the client.
The way to reduce the repetition is to generate the rules from a single definition: field
definitions live in one data structure, and both the markup and the server check are derived
from it. In the two scripts above, the FIELDS object is the counterpart of this definition.
In the Station Document
<fieldset> <legend>Measurement information</legend> <p><label for="station">Station code</label> <input id="station" name="station" type="text" value="north-slope" required maxlength="20" pattern="[a-z-]+" aria-describedby="station-help"> <span id="station-help">Lowercase letters and hyphens only.</span></p> <p><label for="value">Measured value (°C)</label> <input id="value" name="value" type="number" required min="-60" max="60" step="0.1"></p> </fieldset>
The constraints both help the user and announce the contract the document carries: what values this field accepts is written in the document.
Summary
- Constraints are declared as attributes, and when violated produce validity states with defined names; more than one state can be true on a field at the same time.
- The
steprule defines a grid starting from a base value; the base isminif it is written. badInputis the state where the field could not produce a value at all and never reaches the server;tooLongis rare becausemaxlengthalso blocks typing.- The browser stops submission on an invalid field;
novalidateturns this behavior off. - Client validation is a convenience and can be removed; the only place ensuring the data’s consistency is the server, and the same constraints are reapplied there.
Next Step
Validation was built on text and number fields. A file field does not fit these patterns: its value is not a string, fitting it into the body requires a separate encoding, and a size limit cannot be declared in the document. The next lesson looks at file upload and shows the difference between the two submission encodings in the body, through real bytes.
To keep your progress and take notes, Log in
My notes
Log in to take notes.