- Explique une approche de conception Rust qui utilise le système de types pour garantir les invariants à la compilation au lieu de valider à l’exécution
- Définit de nouveaux types (newtypes) comme
NonZeroF32 et NonEmptyVec afin de rendre impossibles les états invalides (0, vecteur vide, etc.)
- Au lieu de renvoyer un échec via
Option ou Result, l’idée est de renforcer les contraintes dès les arguments des fonctions pour bloquer les erreurs en amont
- Présente des exemples comme
String::from_utf8 ou serde_json::from_str, où le parsing transforme des données en types porteurs de sens
- Le principe de conception consistant à rendre les états illégaux impossibles à représenter et à avancer la validation le plus tôt possible améliore la robustesse et la lisibilité du code
1. Exprimer les contraintes par les types plutôt que par la validation à l’exécution
- Dans une fonction
divide(a, b), une division par 0 provoque un panic à l’exécution
- On peut représenter l’échec en renvoyant une
Option, mais cela revient à affaiblir le type de retour
- En définissant un type
NonZeroF32, on permet de créer uniquement des valeurs non nulles
- Le constructeur a la forme
fn new(n: f32) -> Option<NonZeroF32>, et renvoie None en cas d’échec
- Si l’on définit
divide_floats(a: f32, b: NonZeroF32), la validation à l’exécution devient inutile
- La responsabilité de la validation est ainsi déplacée de l’intérieur de la fonction vers l’appelant, ce qui élimine les erreurs en amont
2. Suppression des validations redondantes et simplification du code
- Dans une fonction
roots(a, b, c), si la vérification a == 0 est gérée avec une Option, cela entraîne des validations redondantes à la fois chez l’appelant et dans la fonction
- Avec
NonZeroF32, la validation n’est effectuée qu’une seule fois, puis la logique suivante est simplifiée
- Le même principe s’applique à
NonEmptyVec<T>, qui interdit les vecteurs vides
- Si
get_cfg_dirs() renvoie NonEmptyVec<PathBuf>, aucune validation supplémentaire n’est nécessaire ensuite dans main()
3. Cas concrets : String et serde_json
String est en interne un nouveau type (newtype) autour de Vec<u8>, et String::from_utf8 effectue la vérification de validité
- Ensuite, on peut l’utiliser en toute sécurité comme une chaîne garantie UTF-8
- Avec
serde_json, from_str::<Sample> parse le JSON en structure et garantit au moment de la compilation l’existence des champs et la cohérence des types
- La présence des champs
foo et bar, la correspondance des types, la longueur des tableaux et les autres contraintes sont ainsi vérifiées au niveau des types
4. Deux principes de la conception guidée par les types
- Rendre les états illégaux impossibles à représenter
NonZeroF32 ne peut pas représenter 0, et NonEmptyVec ne peut pas représenter un état vide
- Une simple fonction de validation (
is_nonzero) reste incomplète, car elle laisse encore exister des états invalides
- Effectuer la validation le plus tôt possible
- Si, comme dans le « Shotgun Parsing », la validation est dispersée dans tout le code, cela peut mener à des vulnérabilités de sécurité (comme CVE-2016-0752)
- Si toutes les contraintes sont vérifiées au moment du parsing, la logique suivante peut s’exécuter en toute sûreté
5. Preuves par les types et applications en Rust
- Selon la correspondance de Curry-Howard, les types peuvent être vus comme des propositions logiques, et les valeurs comme leurs preuves
- Avec la crate
typenum, il est possible de vérifier à la compilation des relations mathématiques (3 + 4 = 8)
- Le système de types permet ainsi de prouver la correction d’un programme dès la compilation
6. Conseils d’application en pratique
- Même si une API externe exige des types simples (
bool, i32), il vaut mieux représenter cela en interne avec des enum ou des newtypes porteurs de sens
- Exemple : définir
LightBulbState { On, Off } puis implémenter From<LightBulbState> for bool
- Si vous avez des fonctions comme
verify() ou do_something_fallible(), il faut envisager une conversion en types structurés via le parsing plutôt qu’une simple validation
- Pour une fonction sans effet de bord, on peut aussi utiliser
Result<Infallible, MyError> pour exprimer délibérément un état impossible dans le type
7. Conclusion
- En Rust, utiliser le système de types comme outil de validation améliore la clarté et la robustesse du code
- Plusieurs outils de l’écosystème Rust, comme
Vec, sqlx et bon, s’appuient déjà sur une conception fondée sur les types
- Tous les problèmes ne peuvent pas être résolus uniquement par les types, mais faire remonter la logique de validation au niveau des types améliore la maintenabilité et la sécurité
- Il est recommandé d’exploiter au maximum la puissance du système de types de Rust afin d’écrire du code où le compilateur intercepte les erreurs
1 commentaires
Avis sur Hacker News
L’exemple de la division par zéro utilisé dans cet article n’est pas très adapté pour expliquer le principe « Parse, Don’t Validate »
Le cœur de ce principe réside dans une fonction qui transforme des données non fiables en un type structurellement correct
L’article d’Alexis King "Names are not type safety" explique lui aussi que le pattern
newtypene garantit pas un « correct by construction » completQuand le système de types ne peut pas exprimer directement les invariants, une approche réaliste consiste à utiliser des types abstraits avec des constructeurs intelligents (smart constructors) qui imitent le comportement d’un parseur
Le deuxième exemple, celui d’un vec non vide, est bien meilleur, car il garantit dans le système de types qu’« il y a toujours au moins un élément »
newtypereste en pratique très utileLorsqu’on ne sait pas d’où vient une chaîne, une valeur encapsulée augmente fortement la fiabilité
Pour obtenir un correctness-by-construction complet, il faut un système de types dépendants, mais Rust a aussi des alternatives légères comme les pattern types
On peut par exemple restreindre une plage avec
i8 is 0..100, ou exprimer une slice non vide avec[T] is [_, ..]En revanche, une liste non vide sous la forme
(T, Vec<T>)illustre le conflit entre praticité et pureté théorique, car elle impose beaucoup de contraintes si on veut la manipuler comme un vecteurDes types comme
NonZeroU32sont simples, mais la vraie force consiste à concevoir toute la logique métier dans les types afin que le compilateur joue le rôle de gardienCela déplace la charge du débogage de l’exécution vers la phase de conception
Par exemple "Domain Modeling Made Functional" et la vidéo associée valent le détour
Au lieu d’essayer de l’enrober à ce niveau, il serait plus clair d’encapsuler le comportement de fonctions arithmétiques comme le dépassement de capacité
J’ai regroupé quelques liens vers des discussions récentes sur le sujet
Parse, Don't Validate (2019) (février 2026, 172 commentaires)
Parse, Don’t Validate – Some C Safety Tips (juillet 2025, 73 commentaires)
Parse, Don't Validate (2019) (juillet 2024, 102 commentaires), entre autres
C’est partagé simplement à titre de référence
L’approche parsing over validation a ses limites quand on ne peut pas connaître tous les cas du monde réel
Faire échouer le plus tôt possible est une bonne chose pour des formats de fichiers, mais il faut être prudent lorsqu’on l’applique à la logique métier ou à la modélisation des transitions d’état
Si les exigences du réel changent, le système peut ne plus réussir à les absorber, et les utilisateurs finissent alors par contourner les règles
Dans d’autres langages, on peut aller plus loin avec les types dépendants (dependent typing)
Par exemple,
get_elem_at_index(array, index)peut garantir à la compilation que l’index est dans les bornes, même sans connaître à l’avance la longueur du tableauLes types
Vect n aetFin nd’Idris en sont un exempleExemple : anodized (vidéo de présentation)
Il existe aussi une approche consistant à mettre plusieurs fonctions autour d’un seul type
Comme en Clojure, où toutes les données sont représentées par une map unique, et où l’ensemble de la bibliothèque standard peut les manipuler
On peut intégrer les invariants importants dans les types, ou bien les exprimer par de simples fonctions
Même dans les langages dynamiquement typés, il existe des habitudes de conception qui produisent un effet similaire
Les entrées externes doivent de toute façon finir par être parsées, donc cela ne les remplace pas complètement
Dans un système de types structurel, on peut imiter les types nominatifs avec du branding, et l’inverse est aussi possible, mais ce n’est pas très ergonomique
En pratique, le plus réaliste est de mélanger les deux approches de manière appropriée
Cette discussion fait penser à la fonctionnalité concepts de C++
Dans Concept-based Generic Programming de Bjarne Stroustrup, on montre par exemple une validation automatique des conversions entières
Des types comme
Number<unsigned int>ouNumber<char>lèvent une exception si la valeur sort de l’intervalle autoriséL’exemple
try_rootsde l’article est en fait un contre-exempleEn Rust, exprimer dans les types la contrainte
b^2 - 4ac >= 0devient très complexeDans ce genre de cas, il est plus raisonnable de simplement renvoyer une
Optionet de faire la validation dans la fonctionLa plupart des validations portent sur l’interaction entre plusieurs valeurs, ce qui les rend peu commodes à résoudre par du « parsing »
fn(abc: ValidABC)Ce pattern convient aussi très bien à la conception d’API
Au lieu de valider une requête JSON, on peut dès le départ la parser dans une struct dont les types garantissent la validité, ce qui évite ensuite les validations redondantes dans la logique métier
C’est facile à implémenter en Rust avec la combinaison serde + custom deserializer
J’ai effectivement vu un cas où cette approche a réduit de 60 % le code de gestion d’erreurs
J’applique aussi la même philosophie aux design systems d’interface
Au lieu d’inspecter le CSS après coup, on définit des types qui n’autorisent le placement que par unités de grille, de sorte qu’une marge arbitraire comme 13px devienne une erreur de compilation
Cela permet de garder un design déterministe
Les records + pattern matching de C# s’approchent de cette idée
Les discriminated unions de F# sont encore plus puissantes, car elles permettent avec
Result<'T,'Error>de rendre les états invalides impossibles à représenterC# deviendra sans doute beaucoup plus élégant si des DU natives y sont introduites à l’avenir