07 - Programmation defensive vs offensive : valider aux frontieres, faire confiance a l'intérieur
Ce que tu vas apprendre
- Ce qu'est la programmation defensive (et son coût)
- Ce qu'est la programmation offensive (assertions, crash early)
- La regle des frontieres : ou valider et ou faire confiance
- Le pattern Zod aux frontieres pour valider les donnees externes
- Les assertions en développement
- Pourquoi la sur-validation est un problème
Prerequisites
06 - Gestion des erreurs propre
J'ai travaille sur un projet ou chaque fonction validait chaque paramètre. Chaque. Paramètre. La fonction calculateTax verifiait que le montant etait un nombre positif. La fonction applyTax qui l'appelait verifiait aussi que le montant etait un nombre positif. Et le controller qui appelait applyTax verifiait aussi. Le meme check, trois fois.
Le code ressemblait a ca :
typescriptfunction calculateTax(amount: number, rate: number): number {
if (typeof amount !== "number" || isNaN(amount)) {
throw new Error("amount must be a number");
}
if (amount < 0) throw new Error("amount must be positive");
if (typeof rate !== "number" || isNaN(rate)) {
throw new Error("rate must be a number");
}
if (rate < 0 || rate > 1) throw new Error("rate must be between 0 and 1");
return amount * rate;
}
function applyTax(order: Order): Order {
if (!order) throw new Error("order required");
if (typeof order.total !== "number") throw new Error("total must be number");
if (order.total < 0) throw new Error("total must be positive");
// ... on est encore en train de valider
const tax = calculateTax(order.total, order.taxRate);
return { ...order, tax, totalWithTax: order.total + tax };
}
30% du code etait de la validation. Le vrai travail tenait en 2 lignes. Le ratio signal/bruit etait catastrophique. Et le pire : ces validations cachaient les vrais problèmes. Si order.total etait negatif, le vrai bug etait en amont, pas ici.
Programmation defensive : tout vérifier partout
La programmation defensive part d'un principe : ne fais confiance a personne. Chaque fonction vérifié ses entrees, meme si l'appelant est ton propre code.
Les avantages :
- Chaque fonction est autonome et robuste
- Les erreurs sont detectees au plus pres de la source
- Le code fonctionne meme si l'appelant est bugge
Les inconvenients :
- Code tres verbeux (beaucoup de boilerplate)
- Performance degradee (checks repetes)
- Cache les vrais bugs : si une valeur invalide arrive ici, c'est qu'il y a un bug en amont
- Donne une fausse impression de sécurité
Programmation offensive : crasher tot
La programmation offensive part du principe inverse : si ton propre code t'envoie des donnees invalides, c'est un bug. Et un bug doit crasher, pas etre gere silencieusement.
typescriptfunction calculateTax(amount: number, rate: number): number {
console.assert(amount >= 0, `Bug: negative amount ${amount}`);
console.assert(rate >= 0 && rate <= 1, `Bug: invalid rate ${rate}`);
return amount * rate;
}
Les assert crashent en développement et peuvent etre desactivees en production. Si amount est negatif, c'est que l'appelant a un bug. Le crash force la correction du vrai problème au lieu de le masquer.
La regle des frontieres
La bonne approche combine les deux. Le principe tient en une phrase : valide aux frontieres du système, fais confiance a l'intérieur.
Les frontieres, c'est là où les donnees entrent dans ton système depuis le monde extérieur :
- Les endpoints API (donnees du client HTTP)
- Les handlers de formulaires (donnees de l'utilisateur)
- Les lectures de base de donnees (donnees potentiellement corrompues)
- Les appels a des API tierces (donnees que tu ne contrôles pas)
- Les fichiers de configuration (donnees editees a la main)
- Les variables d'environnement (donnees définies par l'ops)
A l'intérieur du système, entre tes propres fonctions, les donnees sont deja validees. Pas besoin de re-vérifier.
[Monde exterieur] ---> [Frontiere: validation stricte] ---> [Code interne: confiance]
API client Zod, validation Fonctions pures
Base de donnees Type guards Calculs metier
Fichiers config Parsing Transformations
Zod aux frontieres : le pattern qui marche
Zod est une librairie de validation et de parsing pour TypeScript. Elle valide les donnees ET infere les types. C'est l'outil ideal pour les frontieres.
typescriptimport { z } from "zod";
// Schema de validation pour l'API
const CreateOrderSchema = z.object({
items: z
.array(
z.object({
productId: z.string().uuid(),
quantity: z.number().int().positive(),
})
)
.min(1, "Au moins un article requis"),
couponCode: z.string().optional(),
shippingAddress: z.object({
street: z.string().min(1),
city: z.string().min(1),
zipCode: z.string().regex(/^\d{5}$/),
country: z.string().length(2),
}),
});
// Le type est infere automatiquement
type CreateOrderInput = z.infer<typeof CreateOrderSchema>;
Utilisation dans un controller :
typescriptasync function handleCreateOrder(req: Request): Promise<Response> {
// Frontiere : validation stricte
const result = CreateOrderSchema.safeParse(req.body);
if (!result.success) {
return Response.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 400 }
);
}
// A partir d'ici, result.data est type-safe et valide
const order = await orderService.create(result.data);
return Response.json(order, { status: 201 });
}
Apres le safeParse, result.data a le type CreateOrderInput. Plus besoin de vérifier quoi que ce soit dans orderService.create. Les donnees sont propres.
Code interne : faire confiance
Une fois les donnees validees a la frontiere, le code interne les traite sans re-validation :
typescript// Pas de validation ici - les donnees sont deja validees par le controller
function calculateOrderTotal(items: OrderItem[]): number {
return items.reduce(
(sum, item) => sum + item.unitPrice * item.quantity,
0
);
}
function applyShippingFee(total: number, country: string): number {
const fee = country === "FR" ? 0 : 15;
return total + fee;
}
function formatInvoice(order: Order): Invoice {
return {
number: generateInvoiceNumber(),
date: new Date(),
lines: order.items.map((item) => ({
description: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice,
total: item.unitPrice * item.quantity,
})),
total: order.totalWithShipping,
};
}
Pas un seul if (!order) ou if (typeof total !== "number"). Ces fonctions font confiance a leurs appelants. Et c'est normal : elles vivent a l'intérieur du système, pas a la frontiere. On en a parle dans l'article précédent sur la gestion des erreurs.
Assertions en développement
Pour le code interne, les assertions servent de documentation executable. Elles ne remplacent pas la validation aux frontieres. Elles documentent les invariants que le code assume.
typescriptfunction processRefund(order: Order, amount: number): Refund {
// Ces assertions documentent les preconditions
// Elles crashent en dev si un bug en amont les viole
console.assert(order.status === "paid", `Expected paid order, got ${order.status}`);
console.assert(amount > 0, `Refund amount must be positive: ${amount}`);
console.assert(
amount <= order.total,
`Refund ${amount} exceeds order total ${order.total}`
);
return {
orderId: order.id,
amount,
date: new Date(),
status: "pending",
};
}
Si ces assertions echouent, c'est un bug dans le code qui appelle processRefund, pas dans processRefund elle-meme. L'assertion indique au développeur exactement quoi corriger.
Pour des assertions plus robustes, tu peux créer un helper :
typescriptfunction invariant(
condition: unknown,
message: string
): asserts condition {
if (!condition) {
throw new Error(`Invariant violation: ${message}`);
}
}
// Utilisation - TypeScript comprend le narrowing
function getDiscount(user: User): number {
invariant(user.plan !== "free", "Free users should not reach getDiscount");
return user.plan === "premium" ? 0.2 : 0.1;
}
Le asserts condition dit a TypeScript que si la fonction retourne (sans throw), la condition est vraie. Ca combine validation et type narrowing.
Le coût de la sur-validation
La sur-validation n'est pas gratuite :
typescript// 1. Performance : chaque check coute du CPU
function processItems(items: Item[]): Result[] {
// Ce check est execute 10 000 fois par requete
if (!Array.isArray(items)) throw new Error("items must be array");
// ...
}
// 2. Lisibilite : le vrai code est noye dans les checks
function transferMoney(from: Account, to: Account, amount: number) {
if (!from) throw new Error("from required");
if (!to) throw new Error("to required");
if (typeof amount !== "number") throw new Error("amount must be number");
if (isNaN(amount)) throw new Error("amount must not be NaN");
if (amount <= 0) throw new Error("amount must be positive");
if (from.id === to.id) throw new Error("cannot transfer to self");
if (from.currency !== to.currency) throw new Error("currency mismatch");
// 7 lignes de validation pour 2 lignes de logique
from.balance -= amount;
to.balance += amount;
}
La version avec frontieres :
typescript// Validation a la frontiere (controller)
const TransferSchema = z.object({
fromAccountId: z.string().uuid(),
toAccountId: z.string().uuid(),
amount: z.number().positive(),
}).refine(
(data) => data.fromAccountId !== data.toAccountId,
"Cannot transfer to self"
);
// Logique interne : clean
function transferMoney(from: Account, to: Account, amount: number): void {
if (from.currency !== to.currency) {
throw new BusinessError("Currency mismatch");
}
// La verification de devise est une regle metier, pas de la validation d'input
from.balance -= amount;
to.balance += amount;
}
La regle métier (meme devise) reste dans la logique interne. La validation d'input (types, format, non-vide) est a la frontiere. C'est cette séparation qui compte. Je l'applique dans tous mes projets, comme ceux dont je parle sur paltemps.fr.
Résumé en schema
| Zone | Approche | Outil | Exemple |
|---|---|---|---|
| Frontiere API | Defensive (validation stricte) | Zod, express-validator | Parser le body HTTP |
| Frontiere DB | Defensive (type guard) | Zod, runtime checks | Verifier les donnees lues |
| Code métier interne | Offensive (assertions) | console.assert, invariant() |
Documenter les preconditions |
| Fonctions utilitaires | Confiance | Types TypeScript | Pas de validation runtime |
Résumé
- La programmation defensive valide tout partout : robuste mais verbeux
- La programmation offensive crashe sur les bugs internes : direct mais rude
- La bonne approche : validation stricte aux frontieres, confiance a l'intérieur
- Zod est l'outil ideal pour valider et parser les donnees aux frontieres
- Les assertions documentent les invariants internes et attrapent les bugs en dev
- La sur-validation noie le code dans du bruit et cache les vrais problèmes
- Les regles métier restent dans la logique interne, pas dans la couche de validation
Article précédent : 06 - Gestion des erreurs propre
Article suivant : 08 - SOLID en pratique
Sources
- Zod Documentation - https://zod.dev/
- Steve Maguire, "Writing Solid Code" (Microsoft Press) - https://www.microsoftpressstore.com/store/writing-solid-code-9781556155512
- Bertrand Meyer, "Design by Contract" - https://en.wikipedia.org/wiki/Design_by_contract