Skip to content
academia.sh

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 step rule defines a grid starting from a base value; the base is min if it is written.
  • badInput is the state where the field could not produce a value at all and never reaches the server; tooLong is rare because maxlength also blocks typing.
  • The browser stops submission on an invalid field; novalidate turns 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.

Start typing to search.

↑↓ Esc navigate · open · close