---
title: 'Built-in Validation'
source: 'https://academia.sh/en/courses/web-fundamentals-and-html/built-in-validation'
course: 'Web Fundamentals and HTML'
language: en
updated: '2026-08-17T18:09:31+00:00'
license: 'CC BY-SA 4.0'
---

# Built-in Validation

The validity states constraint attributes produce, submission being stopped, and why the same rules need to be reapplied on the server.

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.

```js
// 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", "observer@example.test"],
  ["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       "observer@example.test"     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.

```js
// 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

```html
<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.
