Chaque fois que votre API reçoit une requête, vous la validez. Chaque fois qu’une fonction reçoit un argument d’un système externe, vous le vérifiez. Faites-le manuellement, et un seul endpoint peut accumuler plus de code de validation que de logique métier.
C’est le coût caché des runtime contracts. Vous en avez besoin parce que le système de types ment : le JSON sur HTTP, les champs de base de données et les variables d’environnement arrivent tous comme des données non typées au runtime. Mais les implémenter avec des instructions if écrites à la main transforme des fonctions simples en murailles de code défensif illisibles.
Il existe une meilleure approche.
Ce que sont réellement les runtime contracts
Un runtime contract est une garantie qu’une fonction offre sur ses entrées et ses sorties. Si vous lui passez des données valides, vous obtenez des données valides en retour. Si vous violez le contract, la fonction échoue rapidement avec une erreur explicite.
C’est le Design by Contract, et ça existe depuis Eiffel. L’idée n’est pas nouvelle. Ce qui est nouveau, c’est l’outillage qui permet d’exprimer des contracts sans écrire de validateur pour chaque champ.
L’idée centrale : les contracts sont des schémas, pas du code. Un schéma décrit la forme, les contraintes et les relations. Le code qui vérifie ces éléments manuellement n’est qu’un schéma écrit dans le mauvais langage.
Le piège du boilerplate
Voici à quoi ressemble la validation manuelle en pratique. Vous avez une fonction qui traite une requête d’inscription utilisateur :
function createUser(payload: unknown) {
if (!payload || typeof payload !== "object") {
throw new Error("Invalid payload");
}
if (!("email" in payload) || typeof (payload as any).email !== "string") {
throw new Error("email is required and must be a string");
}
if (!(payload as any).email.includes("@")) {
throw new Error("email must be valid");
}
if (!("age" in payload) || typeof (payload as any).age !== "number") {
throw new Error("age is required and must be a number");
}
if ((payload as any).age < 13 || !Number.isInteger((payload as any).age)) {
throw new Error("age must be an integer of at least 13");
}
if (
"tags" in payload &&
(!Array.isArray((payload as any).tags) ||
!(payload as any).tags.every((t: any) => typeof t === "string"))
) {
throw new Error("tags must be an array of strings");
}
// ... finally, the actual work
}
C’est épuisant, sujet aux erreurs et incomplet. Avez-vous vérifié que email correspond à un vrai format d’adresse email ? Que age n’est pas NaN ? Que les éléments de tags sont des chaînes non vides ? Probablement pas, parce que vous en avez eu assez.
Et ce n’est qu’une seule fonction. Multipliez par chaque boundary de votre système.
Comment les schémas déclaratifs remplacent le boilerplate
Au lieu d’écrire des validateurs, décrivez la forme et laissez une bibliothèque l’appliquer.
import { z } from "zod";
const UserSchema = z.object({
email: z.string().email(),
age: z.number().int().min(13),
tags: z.array(z.string().min(1)).optional(),
});
function createUser(payload: unknown) {
const user = UserSchema.parse(payload);
// user is typed as { email: string; age: number; tags?: string[] }
// actual business logic goes here
}
Le schéma est la documentation. C’est aussi le validateur, la définition de type et le générateur de messages d’erreur. Modifiez le schéma en un seul endroit, et le contract se met à jour partout où il est utilisé.
Ce n’est pas de la magie. La bibliothèque parcourt le schéma au runtime et applique les vérifications. La différence est que les vérifications sont composées à partir de petites primitives réutilisables plutôt qu’écrites à la main à chaque fois.
Les contracts aux boundaries des fonctions
La vraie puissance vient de l’attachement des schémas directement aux entrées et sorties des fonctions. Vous voulez que le contract vive à la boundary, pas qu’il fuite dans l’implémentation.
function withContract<TInput, TOutput>(
inputSchema: z.ZodSchema<TInput>,
outputSchema: z.ZodSchema<TOutput>,
handler: (input: TInput) => TOutput
) {
return (rawInput: unknown): TOutput => {
const input = inputSchema.parse(rawInput);
const output = handler(input);
return outputSchema.parse(output);
};
}
const UserResponseSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
});
const createUser = withContract(
UserSchema,
UserResponseSchema,
(user) => {
// zero validation code here
return db.users.create(user);
}
);
Maintenant, le corps de la fonction ne contient aucun code de validation. Le contract est appliqué au bord, et le compilateur connaît toujours les types à l’intérieur parce que le schéma les infère.
Où ça coince : performance, inférence et sur-validation
Les schémas déclaratifs ne sont pas gratuits. Vous devriez connaître les compromis avant de les adopter.
La performance est l’inquiétude évidente. Parser un schéma complexe est plus lent que quelques vérifications if. En pratique, la différence est négligeable pour la plupart des API. Zod est environ 10 fois plus lent que des vérifications écrites à la main pour des objets simples. Pour la plupart des handlers HTTP, ça représente une différence de microsecondes. Si vous validez des millions d’enregistrements dans une boucle chaude, faites un benchmark d’abord.
Les lacunes d’inférence de type peuvent aussi vous mordre. Les types inférés à partir de schémas sont parfois plus stricts ou plus souples que ce que vous écririez à la main. Vous devrez peut-être définir des types personnalisés et dire au schéma de les matcher, plutôt que l’inverse.
La sur-validation est le tueur silencieux. Toutes les fonctions n’ont pas besoin d’un runtime contract. Les fonctions internes qui reçoivent des données déjà validées à la boundary de l’API n’ont pas besoin d’être revérifiées. Appliquez les contracts aux boundaries du système, pas à chaque fonction utilitaire.
Comment implémenter cela sans réécrire votre codebase
Vous n’avez pas besoin d’une migration big-bang. Commencez par les points d’entrée de votre API.
- Choisissez une fonction exposée à l’extérieur ou un route handler.
- Écrivez un schéma pour son entrée.
- Remplacez la validation manuelle par du parsing de schéma.
- Laissez le schéma inférer le type TypeScript. Supprimez votre interface écrite à la main.
Faites-le pour un endpoint, puis un service, puis un domaine. En une semaine, vous remarquerez que vos suites de tests rétrécissent parce que vous n’avez plus besoin de tester les cas limites de validation que le schéma couvre déjà.
Si vous n’utilisez pas TypeScript, le pattern se transpose. Python a Pydantic. Go a go-playground/validator. Rust a validator. Le principe est le même : décrivez le contract, générez les vérifications.
FAQ
Les runtime contracts remplacent-ils les tests unitaires ?
Non. Ils remplacent le sous-ensemble de tests unitaires qui vérifient la validation des entrées. Vous testez toujours la logique métier, la gestion des erreurs et le comportement d’intégration.
Et les règles de validation personnalisées ?
Chaque bibliothèque de schémas sérieuse prend en charge des raffinements personnalisés. Utilisez-les avec parcimonie. Si votre règle personnalisée fait plus de cinq lignes, elle appartient probablement au corps de la fonction, pas au schéma.
Dois-je aussi valider en sortie ?
Oui, pour les fonctions critiques. Un output contract attrape les bugs là où votre logique métier produit des données malformées. Il est moins coûteux d’échouer à la source que de laisser de mauvaises données se propager vers un autre service.
En résumé
Les runtime contracts sont non négociables pour les systèmes qui manipulent des données non typées. Le seul choix est de savoir si vous les payez avec du boilerplate ou avec une bibliothèque de schémas.
La bibliothèque de schémas vous coûte une dépendance et un léger surcoût de performance. Le boilerplate vous coûte en maintenabilité, en exactitude et en santé mentale.
Choisissez la dépendance.