Valider un formulaire en JavaScript : contrôler les champs avant l'envoi

Un champ peut être valide seul et incohérent avec son voisin. Réserver trois places respecte une limite de six, mais ne convient pas à une session individuelle. Nous allons construire un formulaire d'atelier fictif qui contrôle cette relation, explique les erreurs près des champs et confirme la réponse du serveur. Aucun nom, aucune adresse, aucune réservation réelle : le projet fonctionne uniquement sur votre machine.

Vous aurez quatre fichiers : la page HTML, une fonction de validation, le script d'interface et un petit serveur Node.js. Si les attributs name, required et les labels vous sont nouveaux, commencez par créer un formulaire en HTML. Ici, nous ajoutons la règle entre champs et suivons le parcours jusqu'au serveur.

Le contrat tient en deux choix

La session « individuelle » accepte exactement une place. La session « groupe » en accepte de deux à six. Les deux champs sont obligatoires ; le nombre doit être entier. La validation dans le navigateur sert à corriger la saisie avant l'envoi. Le serveur reprend les règles sur ce qu'il reçoit, même si aucun navigateur n'a participé.

1. HTML : les champs Obligatoires, entier de 1 à 6 2. JavaScript : la relation Individuelle = 1 ; groupe = 2 à 6 3. Serveur : la requête Recontrôler les valeurs reçues Puis refuser ou confirmer
Chaque contrôle a un rôle. Le dernier s'applique aussi aux requêtes envoyées directement.

Le standard HTML, section sécurité des formulaires, consulté le 2 octobre 2026, précise que la validation côté client ne constitue pas un mécanisme de sécurité. Nous vérifierons cette frontière avec une vraie requête HTTP locale, pas avec une simple alerte dans la page.

Commencez par une page qui sait déjà refuser

Créez un dossier atelier, puis ce fichier index.html. required refuse les champs vides ; min, max et step décrivent les limites du nombre. Le HTML ne porte pas encore la relation entre les deux valeurs. Le module référencé à la fin sera créé plus bas.

<!doctype html>
<html lang="fr">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Atelier fictif : validation</title>
</head>
<body>
<main>
  <h1>Simuler une inscription à un atelier</h1>
  <p>Aucune réservation réelle. Tous les champs sont obligatoires.</p>
  <form id="inscription" action="/inscriptions" method="post">
    <fieldset id="champs">
      <legend>Session et nombre de places</legend>
      <p>
        <label for="session">Session</label>
        <select id="session" name="session" required aria-describedby="session-erreur">
          <option value="">Choisissez</option>
          <option value="individuelle">Individuelle (1 place)</option>
          <option value="groupe">Groupe (2 à 6 places)</option>
        </select>
      </p>
      <p id="session-erreur"></p>
      <p>
        <label for="places">Nombre de places (1 à 6)</label>
        <input id="places" name="places" type="number" required
               min="1" max="6" step="1" aria-describedby="places-erreur">
      </p>
      <p id="places-erreur"></p>
      <button type="submit">Tester l'inscription</button>
    </fieldset>
    <p id="resume" role="alert"></p>
    <p id="confirmation" role="status"></p>
  </form>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>

Chaque erreur possède un emplacement lié au champ par aria-describedby. Le résumé porte role="alert" ; la confirmation utilise role="status". Le tutoriel WAI sur les notifications, consulté le 2 octobre 2026, recommande de décrire les erreurs, d'indiquer leur correction et de relier les messages aux champs. Ces attributs préparent l'annonce par les technologies d'assistance ; le banc vérifie leur présence, sans prétendre tester tous les lecteurs d'écran.

Avant d'ajouter JavaScript, « groupe » avec une place passe la validation native : un reste bien entre un et six. Une valeur comme 1,5 ne respecte pas le pas entier. La règle métier doit donc compléter les contraintes du champ, pas les faire disparaître.

Isolez la règle pour la relire sans navigateur

Créez validation.js à côté de la page. La fonction reçoit des valeurs de formulaire, donc le nombre de places sous forme de texte. Elle retourne les erreurs par nom de champ ; un objet vide signifie que les règles ci-dessous sont satisfaites. Elle n'accède ni au DOM ni au réseau.

export function validerInscription(donnees) {
  const erreurs = {};
  const session = donnees.session;
  const texte = typeof donnees.places === "string" ? donnees.places.trim() : "";
  const places = Number(texte);
  if (!["individuelle", "groupe"].includes(session)) {
    erreurs.session = "Choisissez une session.";
  }
  if (texte === "" || !Number.isInteger(places) || places < 1 || places > 6) {
    erreurs.places = "Saisissez un nombre entier de 1 à 6.";
  } else if (session === "individuelle" && places !== 1) {
    erreurs.places = "Une session individuelle demande exactement 1 place.";
  } else if (session === "groupe" && places < 2) {
    erreurs.places = "Une session de groupe demande de 2 à 6 places.";
  }
  return erreurs;
}

Le test du texte vide empêche d'accepter une absence de valeur, même si Number("") vaut zéro. Nous acceptons ici les écritures numériques que Number convertit en entier de un à six, y compris "2.0". Le choix de session, lui, doit appartenir aux deux valeurs autorisées.

Affichez l'erreur, puis laissez-la se corriger

Créez app.js. À la soumission, le script empêche la navigation automatique, vérifie les champs et place le focus sur la première erreur. Après cette première tentative, modifier l'un des champs relance le contrôle des deux : passer de « individuelle » à « groupe » peut rendre trois places valides sans retaper le nombre.

import { validerInscription } from "./validation.js";

const formulaire = document.querySelector("#inscription");
const champs = ["session", "places"].map(id => document.getElementById(id));
const groupe = document.querySelector("#champs");
const resume = document.querySelector("#resume");
const confirmation = document.querySelector("#confirmation");
const bouton = formulaire.querySelector('button[type="submit"]');
let tente = false;

function afficher(erreurs) {
  for (const champ of champs) {
    const message = erreurs[champ.name] ?? "";
    document.getElementById(`${champ.id}-erreur`).textContent = message;
    champ.setAttribute("aria-invalid", String(Boolean(message)));
  }
  resume.textContent = Object.keys(erreurs).length
    ? "Corrigez les champs indiqués avant l'envoi." : "";
}

function verifier() {
  // Effacer l'ancienne erreur avant de lire la validité native.
  for (const champ of champs) champ.setCustomValidity("");
  const donnees = Object.fromEntries(new FormData(formulaire));
  const erreurs = validerInscription(donnees);
  for (const champ of champs) {
    if (!champ.validity.valid && !erreurs[champ.name]) {
      erreurs[champ.name] = "Vérifiez la valeur de ce champ.";
    }
    champ.setCustomValidity(erreurs[champ.name] ?? "");
  }
  afficher(erreurs);
  return formulaire.checkValidity();
}

function focaliserErreur() {
  champs.find(champ => champ.getAttribute("aria-invalid") === "true")?.focus();
}

formulaire.addEventListener("submit", async evenement => {
  evenement.preventDefault();
  if (groupe.disabled) return;
  tente = true;
  confirmation.textContent = "";
  if (!verifier()) {
    focaliserErreur();
    return;
  }
  const corps = new URLSearchParams(new FormData(formulaire));
  groupe.disabled = true;
  confirmation.textContent = "Vérification locale en cours…";
  try {
    const reponse = await fetch(formulaire.action, { method: "POST", body: corps });
    const resultat = await reponse.json();
    groupe.disabled = false;
    confirmation.textContent = "";
    if (reponse.ok && resultat.ok) {
      confirmation.textContent = resultat.message;
      await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
      // Ne pas déplacer un focus que l'utilisateur a placé ailleurs.
      if (document.activeElement === document.body) bouton.focus();
    } else if (reponse.status === 422 && resultat.erreurs) {
      afficher(resultat.erreurs);
      focaliserErreur();
    } else {
      throw new Error("Réponse inattendue");
    }
  } catch {
    resume.textContent = "Serveur local indisponible. Réessayez sans effacer vos valeurs.";
    confirmation.textContent = "";
  } finally {
    groupe.disabled = false;
  }
});

for (const evenement of ["input", "change"]) {
  formulaire.addEventListener(evenement, () => {
    confirmation.textContent = "";
    if (tente) verifier();
  });
}
// Activer notre présentation après l'installation des gestionnaires.
formulaire.noValidate = true;

setCustomValidity intègre notre message à la validité du champ. Une chaîne vide efface l'erreur précédente ; sans cette remise à zéro, une valeur corrigée pourrait rester invalide. checkValidity() donne le résultat du contrôle sans afficher la bulle native. Ces comportements sont définis dans l'API de validation du standard HTML, consultée le 2 octobre 2026.

noValidate est activé après l'installation des gestionnaires : nous prenons en charge la présentation des erreurs tout en lisant validity et en appelant checkValidity(). Sans cette prise en charge, une erreur native peut empêcher l'événement submit d'arriver. Si le module ne charge pas, le formulaire garde les contrôles HTML et son envoi classique ; la réponse du serveur s'affiche alors comme du JSON.

Le script utilise textContent pour écrire les messages et conserve les valeurs en cas d'erreur. Pendant la requête, le fieldset est désactivé après la construction du corps envoyé ; il est réactivé avant une éventuelle correction. Après un succès, le focus revient au bouton seulement si le navigateur l'a laissé sur le document ; un focus placé ailleurs reste en place. La confirmation apparaît uniquement après une réponse positive du serveur. Valider la saisie ne signifie donc pas encore que le serveur l'a acceptée.

Donnez au serveur le dernier mot

Créez enfin serveur.mjs. Ce serveur de démonstration sert les trois fichiers publics et traite POST /inscriptions. Il n'enregistre rien. Le module node:http et sa fonction createServer sont documentés dans la documentation officielle Node.js, consultée le 2 octobre 2026.

import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import { validerInscription } from "./validation.js";

export function creerServeur() {
  return createServer(async (requete, reponse) => {
    const json = (statut, contenu) => {
      reponse.writeHead(statut, { "Content-Type": "application/json; charset=utf-8" });
      reponse.end(JSON.stringify(contenu));
    };
    const fichiers = {
      "/": ["index.html", "text/html"],
      "/app.js": ["app.js", "text/javascript"],
      "/validation.js": ["validation.js", "text/javascript"]
    };
    try {
      if (requete.method === "GET" && fichiers[requete.url]) {
        const [fichier, type] = fichiers[requete.url];
        const contenu = await readFile(new URL(fichier, import.meta.url));
        reponse.writeHead(200, { "Content-Type": `${type}; charset=utf-8` });
        reponse.end(contenu);
        return;
      }
      if (requete.method !== "POST" || requete.url !== "/inscriptions") {
        json(404, { ok: false, message: "Route inconnue." });
        return;
      }
      if (requete.headers["content-type"]?.split(";")[0] !== "application/x-www-form-urlencoded") {
        json(415, { ok: false, message: "Format attendu : formulaire URL-encodé." });
        requete.resume();
        return;
      }
      const morceaux = [];
      let taille = 0;
      for await (const morceau of requete) {
        taille += morceau.length;
        if (taille > 4096) {
          json(413, { ok: false, message: "Requête trop volumineuse." });
          return;
        }
        morceaux.push(morceau);
      }
      const donnees = Object.fromEntries(new URLSearchParams(Buffer.concat(morceaux).toString("utf8")));
      const erreurs = validerInscription(donnees);
      if (Object.keys(erreurs).length) {
        json(422, { ok: false, erreurs });
        return;
      }
      json(200, {
        ok: true,
        message: `Simulation acceptée : session ${donnees.session}, ${Number(donnees.places)} place(s).`
      });
    } catch {
      if (!reponse.headersSent) json(500, { ok: false, message: "Erreur du serveur local." });
      else reponse.end();
    }
  });
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  creerServeur().listen(3000, "127.0.0.1", () => {
    console.log("Atelier fictif : http://127.0.0.1:3000");
  });
}

Le serveur importe la même fonction pour éviter deux règles contradictoires, mais l'exécute de nouveau sur les données reçues. Il refuse aussi un format inattendu et limite le corps à 4 096 octets. Ce cadre local ne représente pas un système de réservation prêt à déployer : il manque notamment stockage, disponibilité réelle des places et protections adaptées à un service public.

Ajoutez ce fichier package.json dans le dossier : il indique à Node.js de lire les fichiers .js comme des modules. Aucune dépendance à installer.

{"private":true,"type":"module"}

Dans un terminal ouvert dans ce dossier, lancez :

node serveur.mjs
Atelier fictif : http://127.0.0.1:3000

Ouvrez cette adresse dans le navigateur. Choisissez « groupe », saisissez une place puis envoyez : le message demande de deux à six places et le focus revient au nombre. Remplacez un par trois : l'erreur s'efface. Envoyez à nouveau : le serveur confirme la simulation. Arrêtez le serveur avec Ctrl+C. Ouvrir directement le HTML en file:// ne fournit ni le serveur ni les modules servis en HTTP.

La requête directe démasque la limite du navigateur

Tout le code ci-dessus a été exécuté le 2 octobre 2026 avec Node.js 20.19.4, Playwright 1.63.0 et Chromium 153.0.8010.12, sur Windows 11. Le banc a aussi extrait les quatre fichiers des blocs publiés et comparé leur contenu aux sources exécutées. Pour reproduire le contrôle serveur sans outil supplémentaire, gardez le serveur lancé et créez requete.mjs :

for (const places of ["1", "3"]) {
  const reponse = await fetch("http://127.0.0.1:3000/inscriptions", {
    method: "POST", body: new URLSearchParams({ session: "groupe", places })
  });
  console.log(`POST groupe/${places} : ${reponse.status} ${await reponse.text()}`);
}

Exécutez node requete.mjs. Cette commande n'ouvre aucun navigateur et n'utilise pas app.js. Voici sa sortie exacte :

POST groupe/1 : 422 {"ok":false,"erreurs":{"places":"Une session de groupe demande de 2 à 6 places."}}
POST groupe/3 : 200 {"ok":true,"message":"Simulation acceptée : session groupe, 3 place(s)."}

Le premier envoi atteint le serveur, mais celui-ci le refuse. Le second est accepté. Dans le parcours navigateur, les relevés Playwright sont les suivants ; natif désigne le contexte sans JavaScript applicatif, et le focus est relevé après deux images de rendu :

natif groupe/1 : true
natif groupe/1.5 : stepMismatch=true
JavaScript groupe/1 : Une session de groupe demande de 2 à 6 places.
focus après refus : places
correction groupe/3 : customError=false
confirmation : Simulation acceptée : session groupe, 3 place(s).
focus après succès : BUTTON
panne : Serveur local indisponible. Réessayez sans effacer vos valeurs.

Les deux premiers booléens mesurent des propriétés différentes : le premier est form.checkValidity(), le second places.validity.stepMismatch. Ainsi, une place est nativement valide, alors que 1,5 produit bien une erreur de pas. Le test de panne interrompt la requête HTTP ; il vérifie qu'aucune confirmation de succès ne remplace le message d'échec.

Pour éprouver votre copie, essayez aussi un champ vide, zéro, sept, 1,5, une session individuelle avec trois places, puis une correction en changeant uniquement la session. Vérifiez le parcours au clavier et les annonces avec votre lecteur d'écran habituel. Le banc automatise les états du DOM et le focus ; cette vérification humaine couvre une autre partie de l'usage.

Si votre prochain projet reçoit des objets plus complexes, valider les données TypeScript avec Zod prolonge ce travail côté serveur. Gardez le même ordre : décrire les valeurs attendues, aider à corriger dans le navigateur, puis contrôler indépendamment la requête reçue.