La validation des données est la frontière entre votre application et le chaos du monde extérieur — saisies de formulaires, charges utiles d'API, variables d'environnement, webhooks tiers. Si une bibliothèque de validation est lourde, vous le payez à chaque chargement de page. Si elle n'est pas typée, vous le payez en bugs de production.
Valibot résout les deux problèmes. C'est une bibliothèque de schémas entièrement typée qui démarre à environ 1,3 Ko compressé, et ne grossit qu'au gré des validateurs que vous importez réellement. Comme chaque validateur est une fonction distincte, les bundlers éliminent par tree-shaking tout ce que vous n'utilisez pas. Le résultat est une couche de validation qui ressemble à Zod mais ne livre qu'une fraction du JavaScript.
Dans ce tutoriel, vous construirez une couche de validation complète pour une application Next.js 15 : schémas, pipelines de transformation, règles personnalisées et asynchrones, gestion des erreurs, et un formulaire Server Action entièrement validé.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé
- Un projet Next.js 15 utilisant l'App Router (ou tout projet TypeScript)
- Des bases en TypeScript sur les génériques et l'inférence
- Un éditeur de code (VS Code recommandé)
Ce que vous allez construire
Une petite fonctionnalité d'« inscription de développeur » avec :
- Un module de schéma réutilisable qui valide noms, e-mails, mots de passe, rôles et étiquettes
- Une inférence de types pour que vos gestionnaires ne redéclarent jamais d'interfaces
- Une Server Action qui analyse
FormDataet renvoie des erreurs au niveau du champ - Une validation asynchrone qui vérifie un e-mail dans une base de données
- Une validation des variables d'environnement qui échoue tôt, au démarrage
À la fin, vous comprendrez le modèle mental de Valibot assez bien pour valider n'importe quoi.
Étape 1 : configuration du projet
Installez Valibot. Il n'a aucune dépendance, c'est donc un seul petit paquet :
npm install valibot
# ou
pnpm add valibotValibot livre des builds ESM et CJS ainsi que des types TypeScript complets. Aucune configuration n'est nécessaire.
La chose la plus importante à connaître sur Valibot est son style d'import. Au lieu d'un grand objet avec des méthodes, chaque fonction est un export nommé. La convention est d'importer tout l'espace de noms sous le nom v :
import * as v from 'valibot';C'est ce qui fait fonctionner le tree-shaking : lorsque votre bundler constate que vous n'avez utilisé que v.string et v.object, il retire les 200+ autres validateurs de votre bundle.
Étape 2 : votre premier schéma
Un schéma décrit la forme des données valides. Commençons par un schéma de connexion :
import * as v from 'valibot';
const LoginSchema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});Deux idées font le travail ici :
v.objectdéfinit un schéma d'objet avec des entrées typées.v.pipeenchaîne un schéma de base avec une ou plusieurs actions (validations et transformations). Lisezv.pipe(v.string(), v.email())ainsi : « ce doit d'abord être une chaîne, puis ressembler à un e-mail. »
Cette composition est le cœur de Valibot. Un schéma de base (v.string, v.number, v.boolean, v.array, v.object) établit le type. Les actions placées après affinent la valeur sans changer le type.
Étape 3 : analyser les données
Un schéma reste inerte tant que vous ne faites pas passer de données à travers lui. Valibot vous offre deux façons de le faire.
parse — lève une erreur en cas d'échec
// Renvoie la valeur typée, ou lève une ValiError
const output = v.parse(LoginSchema, {
email: 'jane@example.com',
password: '12345678',
});Utilisez parse lorsque des données invalides sont véritablement exceptionnelles — comme une variable d'environnement qui doit exister pour que l'application démarre.
safeParse — renvoie un résultat
const result = v.safeParse(LoginSchema, {
email: 'jane@example.com',
password: '12345678',
});
if (result.success) {
// result.output est entièrement typé
console.log(result.output.email);
} else {
// result.issues est un tableau de problèmes de validation
console.log(result.issues);
}safeParse ne lève jamais d'erreur. Il renvoie une union discriminée avec un booléen success, un output en cas de succès, et issues en cas d'échec. C'est l'outil adapté pour une saisie destinée à l'utilisateur, où les erreurs sont attendues et doivent être affichées.
Préférez safeParse pour tout ce que l'utilisateur manipule (formulaires, paramètres de requête, corps de requête) et parse pour les valeurs de confiance mais requises (config, variables d'environnement). Lever une erreur sur une faute de frappe dans un formulaire de contact est une mauvaise expérience ; lever une erreur sur une URL de base de données manquante au démarrage est exactement juste.
Étape 4 : inférer les types TypeScript
C'est ici que Valibot rapporte deux fois. Vous n'avez jamais besoin d'écrire une interface séparée — le schéma est la source de vérité :
const LoginSchema = v.object({
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});
// { email: string; password: string }
type LoginData = v.InferOutput<typeof LoginSchema>;
function authenticate(data: LoginData) {
// data.email et data.password sont typés et validés en amont
}Il existe deux aides à l'inférence, et la différence compte dès que vous ajoutez des transformations :
v.InferOutput— le type après l'analyse et la transformation. C'est ce que votre code consomme.v.InferInput— le type avant l'analyse. C'est ce que l'appelant doit fournir.
Considérez un schéma qui transforme une chaîne en sa longueur :
const ObjectSchema = v.object({
key: v.pipe(
v.string(),
v.transform((input) => input.length)
),
});
type Input = v.InferInput<typeof ObjectSchema>; // { key: string }
type Output = v.InferOutput<typeof ObjectSchema>; // { key: number }L'entrée est une chaîne ; la sortie est un nombre. Utilisez InferInput pour typer les arguments de vos fonctions et InferOutput pour typer le résultat analysé.
Étape 5 : construire un schéma réaliste
Concevons le schéma de notre inscription de développeur. Créez lib/schemas.ts :
import * as v from 'valibot';
export const RoleSchema = v.picklist(['frontend', 'backend', 'fullstack']);
export const RegistrationSchema = v.object({
name: v.pipe(
v.string(),
v.trim(),
v.nonEmpty('Veuillez saisir votre nom.'),
v.maxLength(60, 'Le nom est trop long.')
),
email: v.pipe(
v.string(),
v.trim(),
v.toLowerCase(),
v.email('Veuillez saisir une adresse e-mail valide.')
),
password: v.pipe(
v.string(),
v.minLength(8, 'Le mot de passe doit comporter au moins 8 caractères.'),
v.regex(/[A-Z]/, 'Incluez au moins une majuscule.'),
v.regex(/[0-9]/, 'Incluez au moins un chiffre.')
),
role: RoleSchema,
tags: v.pipe(
v.array(v.pipe(v.string(), v.nonEmpty())),
v.maxLength(5, 'Pas plus de 5 étiquettes autorisées.')
),
newsletter: v.optional(v.boolean(), false),
});
export type Registration = v.InferOutput<typeof RegistrationSchema>;Quelques nouveautés à souligner :
v.picklistrestreint une valeur à un ensemble fixe de littéraux — parfait pour des énumérations comme les rôles.v.trimetv.toLowerCasesont des actions de transformation. Elles normalisent la valeur tandis qu'elle circule dans le pipe, de sorte que" JANE@EXAMPLE.COM "devient"jane@example.com"avant l'exécution du contrôle d'e-mail.v.optional(schema, fallback)rend un champ facultatif et fournit une valeur par défaut, si bien quenewsletterest toujours un booléen en sortie.- Chaque action de validation prend un message facultatif comme dernier argument. Définissez-les délibérément — ils deviennent le texte d'erreur affiché à l'utilisateur.
Étape 6 : validation personnalisée avec check
Les actions intégrées couvrent la plupart des cas, mais les règles inter-champs exigent une logique personnalisée. L'action v.check exécute un prédicat arbitraire et échoue avec votre message s'il renvoie false :
import * as v from 'valibot';
const SignUpSchema = v.pipe(
v.object({
password: v.pipe(v.string(), v.minLength(8)),
confirmPassword: v.string(),
}),
v.check(
(input) => input.password === input.confirmPassword,
'Les mots de passe ne correspondent pas.'
)
);Remarquez que v.check se situe en dehors de v.object, enveloppé par un v.pipe extérieur. C'est parce qu'il a besoin de l'objet entier pour comparer deux champs. Les contrôles au niveau du champ vont dans le pipe du champ ; les contrôles au niveau de l'objet enveloppent l'objet.
Voici un autre exemple validant que la longueur déclarée d'un tableau correspond à son contenu :
const CustomObjectSchema = v.pipe(
v.object({
list: v.array(v.string()),
length: v.number(),
}),
v.check(
(input) => input.list.length === input.length,
'La liste ne correspond pas à la longueur.'
)
);Étape 7 : validation asynchrone
Certaines règles ne peuvent être tranchées que par une base de données ou une API externe — par exemple, « cet e-mail est-il déjà pris ? » Valibot prend en charge les schémas asynchrones via des variantes asynchrones de ses fonctions : v.checkAsync, v.pipeAsync, v.objectAsync et les méthodes v.parseAsync / v.safeParseAsync.
import * as v from 'valibot';
async function isEmailAvailable(email: string): Promise<boolean> {
// Remplacez par une vraie requête en base de données
const taken = await db.user.findUnique({ where: { email } });
return taken === null;
}
const UniqueEmailSchema = v.pipeAsync(
v.string(),
v.email('Veuillez saisir une adresse e-mail valide.'),
v.checkAsync(isEmailAvailable, 'Cet e-mail est déjà enregistré.')
);
// Vous devez attendre un schéma asynchrone :
const result = await v.safeParseAsync(UniqueEmailSchema, 'jane@example.com');Exécutez les validations synchrones avant les asynchrones dans le même pipe. Valibot s'arrête par défaut à la première action en échec, de sorte que les contrôles peu coûteux (format, longueur) rejettent les entrées manifestement mauvaises avant de dépenser un aller-retour vers la base de données.
La règle empirique : si une action quelconque d'un pipe est asynchrone, le schéma entier devient asynchrone, et vous devez utiliser les méthodes d'analyse *Async. Gardez les schémas asynchrones séparés des schémas purement synchrones afin de ne payer le coût de l'asynchrone que là où vous en avez besoin.
Étape 8 : gestion des erreurs avec flatten
Lorsque safeParse échoue, result.issues est un tableau plat de chaque problème trouvé, chacun avec un message et un path. Pour un formulaire, vous voulez plutôt les erreurs regroupées par champ. L'aide v.flatten fait exactement cela :
import * as v from 'valibot';
const FormSchema = v.object({
name: v.pipe(v.string(), v.nonEmpty('Le nom est requis.')),
address: v.object({
city: v.pipe(v.string(), v.nonEmpty('La ville est requise.')),
zip: v.pipe(v.string(), v.regex(/^\d{5}$/, 'Code postal invalide.')),
}),
});
const result = v.safeParse(FormSchema, {
name: '',
address: { city: '', zip: 'abc' },
});
if (!result.success) {
const flat = v.flatten<typeof FormSchema>(result.issues);
console.log(flat);
// {
// nested: {
// name: ['Le nom est requis.'],
// 'address.city': ['La ville est requise.'],
// 'address.zip': ['Code postal invalide.'],
// }
// }
}flatten renvoie jusqu'à trois clés : root (problèmes sur la valeur de premier niveau), nested (problèmes indexés par un chemin séparé par des points) et other (problèmes sans chemin). Pour la plupart des formulaires, vous ne lisez que nested, en associant chaque chemin à la saisie correspondante.
Étape 9 : une Server Action Next.js validée
Maintenant, relions le tout. Dans un projet Next.js 15 avec App Router, une Server Action reçoit FormData, le valide avec Valibot, et renvoie un état typé au client.
Créez app/register/actions.ts :
'use server';
import * as v from 'valibot';
import { RegistrationSchema } from '@/lib/schemas';
export type FormState = {
ok: boolean;
errors?: Record<string, [string, ...string[]]>;
message?: string;
};
export async function registerAction(
_prev: FormState,
formData: FormData
): Promise<FormState> {
// Les valeurs de FormData sont des chaînes ; remodelez avant d'analyser.
const raw = {
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
role: formData.get('role'),
tags: formData.getAll('tags'),
newsletter: formData.get('newsletter') === 'on',
};
const result = v.safeParse(RegistrationSchema, raw);
if (!result.success) {
const flat = v.flatten<typeof RegistrationSchema>(result.issues);
return { ok: false, errors: flat.nested ?? {} };
}
// result.output est un objet Registration entièrement typé et normalisé
await saveDeveloper(result.output);
return { ok: true, message: 'Bienvenue à bord !' };
}Le composant de formulaire utilise useActionState de React 19 pour relier l'action et afficher les erreurs de champ :
'use client';
import { useActionState } from 'react';
import { registerAction, type FormState } from './actions';
const initial: FormState = { ok: false };
export function RegisterForm() {
const [state, action, pending] = useActionState(registerAction, initial);
return (
<form action={action} className="space-y-4">
<div>
<input name="name" placeholder="Nom complet" />
{state.errors?.name && <p className="error">{state.errors.name[0]}</p>}
</div>
<div>
<input name="email" type="email" placeholder="E-mail" />
{state.errors?.email && <p className="error">{state.errors.email[0]}</p>}
</div>
<div>
<input name="password" type="password" placeholder="Mot de passe" />
{state.errors?.password && (
<p className="error">{state.errors.password[0]}</p>
)}
</div>
<select name="role">
<option value="frontend">Frontend</option>
<option value="backend">Backend</option>
<option value="fullstack">Fullstack</option>
</select>
<label>
<input type="checkbox" name="newsletter" /> S'abonner à la newsletter
</label>
<button disabled={pending}>
{pending ? 'Envoi...' : 'S\'inscrire'}
</button>
{state.ok && <p className="success">{state.message}</p>}
</form>
);
}Le même RegistrationSchema garde désormais le client et le serveur. Comme le schéma est l'unique source de vérité, il n'y a aucune dérive entre ce que le formulaire accepte et ce à quoi votre gestionnaire fait confiance.
Étape 10 : valider les variables d'environnement
Un échec de production classique est une variable d'environnement manquante ou malformée découverte à l'exécution. Validez-les une seule fois, au chargement du module, avec parse, afin que l'application refuse de démarrer sur une mauvaise configuration. Créez lib/env.ts :
import * as v from 'valibot';
const EnvSchema = v.object({
DATABASE_URL: v.pipe(v.string(), v.url('DATABASE_URL doit être une URL valide.')),
PORT: v.pipe(
v.optional(v.string(), '3000'),
v.transform(Number),
v.number(),
v.minValue(1)
),
NODE_ENV: v.picklist(['development', 'production', 'test']),
});
// Lève une erreur à l'import si quoi que ce soit manque ou est malformé.
export const env = v.parse(EnvSchema, process.env);Ce motif détecte une mauvaise configuration avant même qu'une seule requête ne soit servie. Notez comment v.transform(Number) transforme la chaîne PORT en un vrai nombre, et comment les v.number() et v.minValue(1) suivants valident la valeur transformée — une démonstration nette du mélange de transformations et de validations dans un seul pipe.
Étape 11 : composer et réutiliser des schémas
À mesure que votre application grandit, vous composez des schémas plutôt que de les réécrire. Valibot livre des utilitaires qui reflètent les opérateurs de type de TypeScript lui-même :
import * as v from 'valibot';
const UserSchema = v.object({
id: v.pipe(v.string(), v.uuid()),
name: v.string(),
email: v.pipe(v.string(), v.email()),
password: v.pipe(v.string(), v.minLength(8)),
});
// Une vue publique sans le mot de passe
const PublicUserSchema = v.omit(UserSchema, ['password']);
// Seulement les champs nécessaires à un formulaire d'inscription
const SignUpSchema = v.pick(UserSchema, ['name', 'email', 'password']);
// Tout facultatif, pour un endpoint PATCH
const UserPatchSchema = v.partial(v.omit(UserSchema, ['id']));Pour des valeurs pouvant prendre plusieurs formes, v.variant sélectionne une branche par une clé discriminante — idéal pour des unions étiquetées comme les événements de webhook :
const EventSchema = v.variant('type', [
v.object({ type: v.literal('created'), id: v.string() }),
v.object({ type: v.literal('deleted'), id: v.string(), reason: v.string() }),
]);Ces utilitaires gardent un schéma canonique unique et en dérivent chaque variation, de sorte qu'une modification de UserSchema se répercute partout automatiquement.
Étape 12 : interopérabilité Standard Schema
Valibot implémente la spécification Standard Schema — une interface partagée adoptée par Zod, ArkType et d'autres. En pratique, cela signifie que les bibliothèques de formulaires et les routeurs acceptent directement un schéma Valibot, sans adaptateur :
// Fonctionne avec TanStack Form, react-hook-form (via le resolver standard),
// tRPC, et tout outil qui parle Standard Schema.
import { useForm } from '@tanstack/react-form';
import { RegistrationSchema } from '@/lib/schemas';
const form = useForm({
validators: { onChange: RegistrationSchema },
});Cette interopérabilité explique pourquoi vous pouvez adopter Valibot progressivement : substituez-le derrière la même interface que vos outils attendent déjà.
Tester votre implémentation
Vérifiez les comportements principaux avec un test rapide à l'aide de votre exécuteur préféré :
import * as v from 'valibot';
import { describe, it, expect } from 'vitest';
import { RegistrationSchema } from '@/lib/schemas';
describe('RegistrationSchema', () => {
it('normalise et accepte une entrée valide', () => {
const result = v.safeParse(RegistrationSchema, {
name: ' Jane ',
email: 'JANE@EXAMPLE.COM',
password: 'Secret123',
role: 'fullstack',
tags: ['react'],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.output.name).toBe('Jane');
expect(result.output.email).toBe('jane@example.com');
expect(result.output.newsletter).toBe(false);
}
});
it('signale des erreurs au niveau du champ', () => {
const result = v.safeParse(RegistrationSchema, {
name: '',
email: 'nope',
password: 'short',
role: 'fullstack',
tags: [],
});
expect(result.success).toBe(false);
if (!result.success) {
const flat = v.flatten<typeof RegistrationSchema>(result.issues);
expect(flat.nested?.name).toBeDefined();
expect(flat.nested?.email).toBeDefined();
}
});
});Exécutez-le et confirmez que les deux cas passent. Le premier affirme que les transformations s'exécutent (trim, lowercase, valeur par défaut) ; le second affirme que flatten produit des messages indexés par champ.
Dépannage
« Type instantiation is excessively deep » sur un grand schéma. Cela vient généralement de chaînes v.pipe profondément imbriquées. Extrayez les sous-schémas dans des constantes nommées et référencez-les ; les plus petites pièces se vérifient plus vite et se lisent mieux.
Un contrôle asynchrone ne s'exécute jamais. Vous avez probablement appelé v.safeParse au lieu de v.safeParseAsync, ou construit le pipe avec v.pipe au lieu de v.pipeAsync. Toute action asynchrone exige la méthode d'analyse asynchrone et le pipe asynchrone.
InferInput et InferOutput diffèrent de façon inattendue. C'est voulu lorsque vous utilisez v.transform. Typez les arguments de votre fonction avec InferInput (ce que les appelants envoient) et vos résultats avec InferOutput (ce qu'ils reçoivent après analyse).
Bundle plus gros que prévu. Assurez-vous d'importer avec import * as v from 'valibot' et laissez votre bundler faire le tree-shaking. Évitez de réexporter tout l'espace de noms depuis un fichier baril, ce qui peut neutraliser l'élimination du code mort.
Étapes suivantes
- Ajoutez
v.brandpour créer des types nominaux (unUserIdnon interchangeable avec une autre chaîne). - Explorez
v.fallbackpour récupérer élégamment des valeurs invalides au lieu d'échouer. - Reliez vos schémas à une API tRPC ou Hono afin que les corps de requête soient validés à la périphérie.
- Comparez l'impact sur le bundle par rapport à votre validateur actuel avec un outil comme
bundlejspour quantifier l'économie.
Conclusion
Valibot vous offre une ergonomie de niveau Zod et une inférence TypeScript complète tout en livrant une fraction du JavaScript, grâce à sa conception modulaire et compatible tree-shaking. Vous disposez désormais d'un motif complet : définissez les schémas une fois, dérivez les types avec InferOutput, analysez en toute sécurité avec safeParse, regroupez les erreurs avec flatten, gérez les règles asynchrones avec la famille *Async, et gardez une Server Action Next.js de bout en bout.
La leçon plus large est architecturale : lorsqu'un seul schéma est la source de vérité de vos types, de vos formulaires, de votre API et de votre configuration, des catégories entières de bugs deviennent tout simplement impossibles. Validez à la frontière, inférez partout ailleurs, et laissez le compilateur porter le reste.