Validate a form server-side
Check that the data received from a form is complete and coherent before storing it.
RecommendedN0 Reviewed on
| Rung | Approach | Cost | Latency | Data | Deterministic | Verdict |
|---|---|---|---|---|---|---|
| N0 — Rule and classic algorithm | Declarative schema, one error message per field | None | <1 ms | Nothing leaves | Yes | Recommended |
| N1 — Lightweight classic model | Rung not applicable There is nothing to learn. A minimum age, a display name length, an accepted address format: these are written decisions you can be held to, not regularities waiting in data. A classifier trained on past submissions would learn what was accepted yesterday, mistakes included, and still could not name the field to fix. | |||||
| N2 — Small self-hosted specialised model | Rung not applicable The same reason as N1, plus a permanent service to run. A self-hosted model does not make a rule any more correct: it makes the verdict costlier to obtain, slower to return, and impossible to read back in the schema the team maintains. | |||||
| N3 — General-purpose LLM API | Rung not applicable A validation has to refuse deterministically, and to justify the refusal to the person whose entry it refuses. A model that sometimes accepts and sometimes refuses the same submission validates nothing: it offers an opinion. Two identical submissions must get the same answer, and that answer must be readable in a rule you can produce — a schema, a line of code, a test — not in a sentence whose production you can neither replay nor ground. On top of that, a form carries text written by the very person being validated: handing it to a model that reads text as instruction lets them lean on the verdict that concerns them. | |||||
N0 — Rule and classic algorithm Rule and classic algorithm Recommended
Declarative schema, one error message per field
- Cost
- None
- Latency
<1 ms
Proof of execution : Code runs as shown
This snippet runs with its real dependencies, and its test runs on every build of the site.
Python
"""
Validate a form on the server: a declarative schema, one error per field.
Rung N0. Deterministic, standard library only. The whole point of this entry is
that a validator worth having fits in forty lines, so the rules stay readable
and every refusal can be explained to the person who typed the form.
Three decisions carry the design.
First, the schema is data, not code. It can be written next to the form, read by
someone who does not write Python, and compared with the JavaScript one that
guards the same form in the browser.
Second, the answer is a mapping of field name to message, never a boolean. A
form that answers "no" without saying which field is wrong sends the user
hunting, and sends the developer to the logs.
Third, one message per field: checks stop at the first broken rule. Telling
someone their password is too short *and* badly formed at once is noise; fix
the first thing, resubmit, see the next.
"""
import re
# The types a form field can hold once decoded. `bool` is excluded from
# `integer` on purpose: in Python a boolean *is* an int, and a checkbox is not
# an age.
TYPES = {
"string": lambda v: isinstance(v, str),
"integer": lambda v: isinstance(v, int) and not isinstance(v, bool),
}
def check(value, rule):
"""Return the first broken rule as a message, or None if the value passes."""
kind = rule.get("type", "string")
if not TYPES[kind](value):
return f"must be of type {kind}"
# For a string the bounds read as a length; for a number, as a value.
size, unit = (len(value), " characters") if kind == "string" else (value, "")
if "min" in rule and size < rule["min"]:
return f"must be at least {rule['min']}{unit}"
if "max" in rule and size > rule["max"]:
return f"must be at most {rule['max']}{unit}"
if "pattern" in rule and not re.fullmatch(rule["pattern"], value):
return rule.get("message", "is not in the expected format")
return None
def validate(data, schema):
"""
Check a submitted form against a schema, and return {field: message}.
An empty mapping means the form is valid. A missing key, an explicit None
and an empty string are the same thing here, because that is what a browser
posts for a field the user left alone.
"""
errors = {}
for field, rule in schema.items():
value = data.get(field)
if value is None or value == "":
if rule.get("required"):
errors[field] = "is required"
continue
message = check(value, rule)
if message is not None:
errors[field] = message
return errorsJavaScript
/**
* Validate a form on the server: a declarative schema, one error per field.
*
* Rung N0. Deterministic, no dependency. The whole point of this entry is that
* a validator worth having fits in forty lines, so the rules stay readable and
* every refusal can be explained to the person who typed the form.
*
* Three decisions carry the design.
*
* First, the schema is data, not code. It can be written next to the form, read
* by someone who does not write JavaScript, and compared with the Python one
* that guards the same form on the other side.
*
* Second, the answer is a mapping of field name to message, never a boolean. A
* form that answers "no" without saying which field is wrong sends the user
* hunting, and sends the developer to the logs.
*
* Third, one message per field: checks stop at the first broken rule. Telling
* someone their password is too short *and* badly formed at once is noise; fix
* the first thing, resubmit, see the next.
*/
// The types a form field can hold once decoded. `Number.isInteger` rejects
// NaN, Infinity and 1.5 in one go, which is what a form needs.
const TYPES = {
string: (v) => typeof v === 'string',
integer: (v) => Number.isInteger(v),
};
/** Return the first broken rule as a message, or null if the value passes. */
export function check(value, rule) {
const kind = rule.type ?? 'string';
if (!TYPES[kind](value)) return `must be of type ${kind}`;
// For a string the bounds read as a length; for a number, as a value.
const [size, unit] = kind === 'string' ? [value.length, ' characters'] : [value, ''];
if (rule.min !== undefined && size < rule.min) return `must be at least ${rule.min}${unit}`;
if (rule.max !== undefined && size > rule.max) return `must be at most ${rule.max}${unit}`;
// Anchored, so a pattern matches the whole field and not a fragment of it.
if (rule.pattern !== undefined && !new RegExp(`^(?:${rule.pattern})$`).test(value)) {
return rule.message ?? 'is not in the expected format';
}
return null;
}
/**
* Check a submitted form against a schema, and return {field: message}.
*
* An empty object means the form is valid. A missing key, an explicit null and
* an empty string are the same thing here, because that is what a browser posts
* for a field the user left alone.
*/
export function validate(data, schema) {
const errors = {};
for (const [field, rule] of Object.entries(schema)) {
const value = data[field];
if (value === undefined || value === null || value === '') {
if (rule.required) errors[field] = 'is required';
continue;
}
const message = check(value, rule);
if (message !== null) errors[field] = message;
}
return errors;
}Risks
- Data leaving
- Nothing leaves
- Determinism
- Yes
- Testability
- Unit testable
- Vendor dependency
- None
- Footprint
- Negligible
- Regulatory scope
-
- No specific scope added: the submission never leaves your infrastructure
- Messages name the field and the rule it broke, never the value typed: what ends up in your error logs stays what you put there
Breaking point
No rule can tell whether what was typed exists. The test submits `ada@no-such-mailbox.example`: the shape is right, the schema accepts it, and nobody reads mail sent there. Finding out takes an external check — a confirmation message, a lookup on the domain — which is a different need.
When to move up a rung
Well-formed but false entries start costing you something you can see: accounts created that never confirm, mail that bounces. No rung above answers that; what you need is an external check, which is a different need.
N1 — Lightweight classic model Lightweight classic model
Rung not applicable
There is nothing to learn. A minimum age, a display name length, an accepted address format: these are written decisions you can be held to, not regularities waiting in data. A classifier trained on past submissions would learn what was accepted yesterday, mistakes included, and still could not name the field to fix.
N2 — Small self-hosted specialised model Small self-hosted specialised model
Rung not applicable
The same reason as N1, plus a permanent service to run. A self-hosted model does not make a rule any more correct: it makes the verdict costlier to obtain, slower to return, and impossible to read back in the schema the team maintains.
N3 — General-purpose LLM API General-purpose LLM API
Rung not applicable
A validation has to refuse deterministically, and to justify the refusal to the person whose entry it refuses. A model that sometimes accepts and sometimes refuses the same submission validates nothing: it offers an opinion. Two identical submissions must get the same answer, and that answer must be readable in a rule you can produce — a schema, a line of code, a test — not in a sentence whose production you can neither replay nor ground. On top of that, a form carries text written by the very person being validated: handing it to a model that reads text as instruction lets them lean on the verdict that concerns them.
The verdict
RecommendedN0
N0 is not the first step of a staircase here, it is the only step that holds. A form rule is written before it is enforced: you can read it, unit test it, and show it to whoever disputes it. The three rungs above would trade that property for a decision you can neither replay nor justify, which is precisely what a validation is asked for.
Further reading
- JSON Schema — Validation, le vocabulaire d'un schéma déclaratif
- OWASP — Input Validation Cheat Sheet
- WHATWG HTML — définition normative d'une adresse électronique valide
- MDN — Validation de formulaire, et pourquoi le contrôle côté client ne suffit pas