Catalogue Decide and validate

Validate a form server-side

Check that the data received from a form is complete and coherent before storing it.

RecommendedN0 Reviewed on

Rungs at a glance
Rung Approach Cost Latency Data Deterministic Verdict
Rule and classic algorithm N0 — Rule and classic algorithm Declarative schema, one error message per field None <1 ms Nothing leaves Yes Recommended
Lightweight classic model 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.
Small self-hosted specialised model 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.
General-purpose LLM API 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

snippets/validate-a-form-server-side/n0.py
"""
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 errors

JavaScript

snippets/validate-a-form-server-side/n0.js
/**
 * 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

Metadata