Vol. 3 · № 03 · Un atelier RustA Rust workshop

Newtype & type-state, ou l'état illégal qui ne compile pas Newtype & type-state, or the illegal state that won't compile

Un Connection<Open> et un Connection<Closed> qui ne sont pas le même type : send() n'existe simplement pas sur une connexion fermée. Contre-exemple : la cérémonie qui coûte plus que le bug. A Connection<Open> and a Connection<Closed> that aren't the same type: send() simply doesn't exist on a closed connection. Counter-example: the ceremony that costs more than the bug.

AudienceAudience
Dev maîtrisant enums et Option (Vol 3 №1–2) Dev fluent in enums and Option (Vol 3 №1–2)
Format
Self-paced
ChapitresChapters
5
Date
Juin 2026 Jun 2026
≈ 18 min ●●●○ TypesType-stateInvariants
SommaireContents ·
№ 01CadrageFraming3 min

Tes propres invariants, déplacés de ta tête vers le type.Your own invariants, moved from your head into the type.

Option a montré le mouvement : prendre une règle qui vivait dans ta tête — « ça peut être absent » — et la déplacer dans le type, où le compilateur la fait respecter. Ce numéro généralise le geste à tes propres règles. Deux outils : le newtype, qui donne un sens et une garantie à un type primitif ; et le type-state, qui encode l'état d'un objet dans son type, pour que les opérations invalides n'existent tout simplement pas. La fin du Vol 3 : rendre l'illégal non pas détectable, mais impossible à écrire — dans ton domaine, pas seulement dans la bibliothèque standard.Option showed the move: take a rule that lived in your head — 'this can be absent' — and move it into the type, where the compiler enforces it. This issue generalizes the gesture to your own rules. Two tools: the newtype, which gives meaning and a guarantee to a primitive type; and type-state, which encodes an object's state into its type, so invalid operations simply don't exist. The close of Vol 3: making the illegal not detectable but impossible to write — in your domain, not just the standard library.

Le newtype — un primitif, mais avec un sens et une garantieThe newtype — a primitive, but with meaning and a guarantee
struct Email(String);          // un newtype : un String, mais PAS un String

impl Email {
    // le SEUL chemin de construction passe par la validation
    fn parse(s: String) -> Option<Email> {
        if s.contains('@') { Some(Email(s)) } else { None }
    }
}

// dès lors, un Email qui existe est un email validé — par construction.
// et on ne peut plus le confondre avec un mot de passe, lui aussi String.
« Parse, don't validate »'Parse, don't validate'

L'idée tient en une formule : ne valide pas une donnée pour la laisser ensuite circuler sous son type brut — transforme-la en un type qui prouve sa validité. Un String validé reste un String : trois fonctions plus loin, plus rien ne dit qu'il l'a été. Un Email issu de parse porte sa garantie partout où il va — la validation a eu lieu une fois, à la frontière, et le type s'en souvient pour toujours.The idea fits in a phrase: don't validate data only to let it flow on under its raw type — transform it into a type that proves its validity. A validated String is still a String: three functions later, nothing says it ever was. An Email from parse carries its guarantee everywhere it goes — validation happened once, at the boundary, and the type remembers it forever.

Le réflexe du numéroThe issue's reflex

« Cette règle, je la vérifie à chaque usage, ou je l'encode une fois dans un type ? » Si une donnée a un invariant — validée, normalisée, dans une certaine unité, dans un certain état — un type dédié le porte une bonne fois. Le compilateur cesse alors de te faire confiance pour t'en souvenir, et le fait à ta place, à chaque appel.'This rule — do I check it at every use, or encode it once in a type?' If a piece of data has an invariant — validated, normalized, in a certain unit, in a certain state — a dedicated type carries it once and for all. The compiler then stops trusting you to remember it, and does so for you, at every call.

Coût à l'exécution : zéro.Runtime cost: zero.

Un newtype comme Email(String) ne coûte rien à l'exécution : c'est exactement la représentation mémoire d'un String, l'enveloppe disparaît à la compilation. On parle d'abstraction « à coût nul » — un thème qui traversera tout le reste de la série. La sécurité de type est une affaire de compilation, pas de cycles CPU.A newtype like Email(String) costs nothing at runtime: it has exactly a String's memory representation, the wrapper vanishes at compile time. This is 'zero-cost' abstraction — a theme running through the rest of the series. Type safety is a compile-time matter, not CPU cycles.

№ 02La surpriseThe surprise4 min

Quand tout est String, le compilateur ne peut plus rien distinguer.When everything is a String, the compiler can no longer tell things apart.

L'« obsession du primitif » est un réflexe transparent : un email est du texte, donc String ; un âge est un nombre, donc u32 ; un identifiant aussi, donc u32. Le code compile, paraît simple — et le compilateur, qui ne voit que des String et des u32 interchangeables, ne peut plus t'empêcher d'inverser deux arguments, de passer une donnée non validée, de confondre un identifiant client et un identifiant produit. Toute la sécurité retombe sur la vigilance humaine.'Primitive obsession' is a transparent reflex: an email is text, so String; an age is a number, so u32; an id too, so u32. The code compiles, looks simple — and the compiler, seeing only interchangeable Strings and u32s, can no longer stop you swapping two arguments, passing unvalidated data, confusing a customer id with a product id. All the safety falls back on human vigilance.

Le contre-exemple — trois String, mille façons de se tromperThe counter-example — three Strings, a thousand ways to slip
// Tout est String / u32 : le compilateur ne voit que des primitifs
fn creer_compte(email: String, mdp: String, age: u32) { /* ... */ }

creer_compte(mdp, email, age);     // ✓ COMPILE — arguments inversés !
creer_compte("pasunemail".into(), "x".into(), 200);  // ✓ COMPILE — invalide

// rien ne distingue un email d'un mot de passe, un âge d'un identifiant.
// le type String dit « du texte », jamais « un email validé ».

Le compilateur ne refuse rien, parce qu'il n'a aucun moyen de savoir qu'email et mdp jouent des rôles différents : ce sont le même type. Inverser deux arguments du même type est l'un des bugs les plus pernicieux qui soient — il passe la compilation, les tests heureux, et se révèle sur des données réelles. Le type String ne porte qu'une information : « c'est du texte ». Tout le sens métier est resté dans ta tête.The compiler refuses nothing, because it has no way to know email and mdp play different roles: they're the same type. Swapping two arguments of the same type is among the most insidious bugs there is — it passes compilation, the happy tests, and surfaces on real data. The String type carries one piece of information: 'this is text'. All the domain meaning stayed in your head.

Un type primitif partout, c'est demander au compilateur de surveiller un sens qu'on ne lui a jamais dit.A primitive type everywhere is asking the compiler to guard a meaning you never told it about.
№ 03La mécaniqueThe mechanics5 min

Le type-state : l'opération interdite qui n'existe tout simplement pas.Type-state: the forbidden operation that simply doesn't exist.

Le newtype encode un invariant sur une valeur. Le type-state va plus loin : il encode l'état d'un objet dans son type, et attache chaque opération à l'état où elle est légale. Une connexion fermée et une connexion ouverte ne sont pas le même type — et send() n'est défini que sur la seconde. Appeler send() sur une connexion fermée n'est pas une erreur qu'on attrape : c'est un appel à une méthode qui n'existe pas. L'erreur est éliminée à la racine, par la disponibilité même des opérations.The newtype encodes an invariant about a value. Type-state goes further: it encodes an object's state into its type, and attaches each operation to the state where it's legal. A closed connection and an open one aren't the same type — and send() is defined only on the latter. Calling send() on a closed connection isn't an error you catch: it's a call to a method that doesn't exist. The error is eliminated at the root, by the very availability of operations.

L'état dans le type — chaque méthode sur l'état qui la rend valideState in the type — each method on the state that makes it valid
struct Connexion<E> { socket: Socket, _etat: PhantomData<E> }
struct Ouverte;          // marqueurs d'état — zéro octet à l'exécution
struct Fermee;

impl Connexion<Fermee> {
    fn new() -> Self { /* ... */ }                         // on naît Fermée
    fn ouvrir(self) -> Connexion<Ouverte> { /* ... */ }   // Fermée → Ouverte
}
impl Connexion<Ouverte> {
    fn envoyer(&self, msg: &str) { /* ... */ }            // n'existe QUE sur Ouverte
    fn fermer(self) -> Connexion<Fermee> { /* ... */ }    // Ouverte → Fermée
}

let c = Connexion::<Fermee>::new();
c.envoyer("salut");      // ✕ ne compile pas : envoyer() n'existe pas sur Fermee
let c = c.ouvrir();      // c est maintenant Connexion<Ouverte>
c.envoyer("salut");      // ✓ envoyer() existe sur Ouverte
La transition consomme l'ancien étatThe transition consumes the old state
Connexion<Fermee>
ouvrir()
ouvrir()
→
Connexion<Ouverte>
envoyer(), fermer()
fermer()
↩

Chaque transition consomme self par move (le Vol 1 revient ici) et renvoie le type du nouvel état. L'ancienne valeur n'existe plus — impossible de garder par mégarde une Connexion<Ouverte> après l'avoir fermée. Le graphe d'états légaux est littéralement encodé dans les signatures : les seules transitions possibles sont celles pour lesquelles une méthode existe.Each transition consumes self by move (Vol 1 returns here) and returns the new state's type. The old value no longer exists — you can't accidentally keep a Connexion<Ouverte> after closing it. The graph of legal states is literally encoded in the signatures: the only possible transitions are those for which a method exists.

Ce que ça remplaceWhat it replaces

Sans type-state, on écrirait un champ est_ouverte: bool et un if !est_ouverte { panic!() } au début de envoyer. La vérification existe, mais à l'exécution, et il faut penser à la mettre partout. Le type-state la fait disparaître : il n'y a plus rien à vérifier, parce qu'il n'y a plus d'état illégal atteignable.Without type-state, you'd write an is_open: bool field and an if !is_open { panic!() } at the top of send. The check exists, but at runtime, and you must remember it everywhere. Type-state makes it vanish: there's nothing left to check, because there's no reachable illegal state.

PhantomData, le marqueur fantôme.PhantomData, the ghost marker.

Le paramètre E n'apparaît dans aucun champ réel : PhantomData<E> dit au compilateur « ce type est paramétré par E » sans occuper le moindre octet. Ouverte et Fermee sont des structs vides, des étiquettes pures. Tout le type-state est une construction de compilation : à l'exécution, il ne reste qu'un Socket. Abstraction à coût nul, encore.The E parameter appears in no real field: PhantomData<E> tells the compiler 'this type is parameterized by E' without taking a single byte. Ouverte and Fermee are empty structs, pure labels. All of type-state is a compile-time construct: at runtime, only a Socket remains. Zero-cost abstraction, again.

On n'attrape pas l'appel illégal. On fait en sorte que la méthode n'existe pas sur l'état où l'appel serait illégal.You don't catch the illegal call. You arrange for the method not to exist on the state where the call would be illegal.
№ 04Le piègeThe trap4 min

La cérémonie de types qui coûte plus cher que le bug qu'elle prévient.The type ceremony that costs more than the bug it prevents.

Une fois qu'on a vu la puissance du newtype et du type-state, la tentation est de tout encoder. Chaque champ devient un type, chaque état un paramètre générique, chaque transition une méthode qui consomme self. Mais l'encodage a un coût : du boilerplate, des conversions, une signature plus lourde, une courbe de lecture plus raide. Quand ce coût dépasse le risque du bug évité, on a sur-conçu. Le bon design n'est pas « tout typer » — c'est typer ce qui porte un vrai danger.Once you've seen the power of newtype and type-state, the temptation is to encode everything. Every field becomes a type, every state a generic parameter, every transition a self-consuming method. But encoding has a cost: boilerplate, conversions, heavier signatures, a steeper reading curve. When that cost exceeds the risk of the bug avoided, you've over-engineered. Good design isn't 'type everything' — it's typing what carries real danger.

Le contre-exemple — un newtype par champ, sans le moindre invariantThe counter-example — one newtype per field, with no invariant at all
// Newtype sur TOUT, même ce qui n'a aucun invariant
struct Prenom(String);
struct Nom(String);
struct Rue(String);
struct Ville(String);
struct CodePostal(String);   // … et 20 autres

impl Prenom {
    fn new(s: String) -> Prenom { Prenom(s) }   // aucune validation : pure cérémonie
    fn as_str(&self) -> &str { &self.0 }
}
// 200 lignes de boilerplate pour des champs qu'aucune règle ne contraint.
// Chaque accès devient .0 ou .as_str() ; chaque appel, un emballage.

Prenom(String) sans validation ne garantit rien que String ne garantissait déjà — il ajoute juste de l'emballage. Le vrai danger qu'un newtype prévient, c'est la confusion ou la donnée invalide. Là où ces dangers n'existent pas (qui inverse un prénom et un nom de rue par accident, et quelle catastrophe si oui ?), le type ne fait que rançonner chaque ligne en .0 et conversions. Le lecteur paie une dette de cérémonie sans recevoir de garantie en échange.Prenom(String) with no validation guarantees nothing String didn't already — it just adds wrapping. The real danger a newtype prevents is confusion or invalid data. Where those dangers don't exist (who swaps a first name and a street name by accident, and what disaster if they do?), the type merely taxes every line with .0 and conversions. The reader pays a ceremony debt without getting a guarantee in return.

Le bon réflexe — typer les invariants qui portent un risqueThe right reflex — type the invariants that carry risk
// On encode SEULEMENT les invariants qui portent un risque réel
struct Email(String);        // invariant fort : doit être validé
struct CodePostal(String);   // invariant : 5 chiffres — vaut un newtype

struct Adresse {             // le reste : de simples champs String
    rue: String,
    ville: String,
    code_postal: CodePostal, // seul le champ à risque est typé
}
// La cérémonie est réservée aux endroits où elle prévient un vrai bug.

On garde le newtype là où il travaille : un invariant réel (un email validé, un code postal à 5 chiffres), ou un risque de confusion coûteux (un IdClient qu'on ne veut jamais passer pour un IdProduit). Le reste reste primitif. La question n'est pas « puis-je typer ceci », mais « le bug que ce type prévient justifie-t-il son poids ». Le type-state, plus lourd encore, se réserve aux machines à états où une transition illégale serait vraiment dommageable — un protocole réseau, un cycle de vie de ressource.You keep the newtype where it works: a real invariant (a validated email, a 5-digit postcode), or a costly confusion risk (a ClientId you never want passed as a ProductId). The rest stays primitive. The question isn't 'can I type this', but 'does the bug this type prevents justify its weight'. Type-state, heavier still, is reserved for state machines where an illegal transition would be genuinely harmful — a network protocol, a resource lifecycle.

Le bon dosageCalibrating it

Trois questions avant d'encoder : (1) y a-t-il un invariant réel, ou juste un nom ? (2) le bug évité est-il fréquent ou coûteux ? (3) le poids ajouté (boilerplate, lecture) reste-t-il sous le bénéfice ? Trois oui : encode. Un non : laisse le primitif, ou un simple assert à la frontière.Three questions before encoding: (1) is there a real invariant, or just a name? (2) is the avoided bug frequent or costly? (3) does the added weight (boilerplate, reading) stay below the benefit? Three yeses: encode. One no: leave the primitive, or a simple assert at the boundary.

Le test mentalThe mental test

« Si je supprimais ce type pour revenir au primitif, quel bug réel redeviendrait possible ? » Si tu peux nommer ce bug et qu'il fait mal, le type gagne son poids. Si la seule réponse est « ce serait moins joli » ou « par cohérence », c'est de la cérémonie — et la cohérence d'un sur-typage généralisé coûte plus qu'elle ne rapporte.'If I removed this type and went back to the primitive, what real bug would become possible again?' If you can name that bug and it hurts, the type earns its weight. If the only answer is 'it'd be less tidy' or 'for consistency', it's ceremony — and the consistency of blanket over-typing costs more than it returns.

Un type doit gagner son poids : s'il ne prévient aucun bug nommable, il n'est pas de la sûreté — c'est de la cérémonie.A type must earn its weight: if it prevents no nameable bug, it isn't safety — it's ceremony.
№ 05Bilan & éditoWrap-up & editorial2 min

Le type comme lieu où vivent les règles du programme.The type as the place where the program's rules live.

Le Vol 3 a suivi une seule idée à travers trois numéros : déplacer les règles de ta tête vers le type. L'enum borne les états possibles ; Option borne l'absence ; le newtype et le type-state bornent tes invariants à toi. À chaque étape, le mouvement est le même — l'illégal cesse d'être quelque chose qu'on vérifie pour devenir quelque chose qu'on ne peut pas écrire. Et le dernier numéro a posé la limite : ce pouvoir a un coût, et on ne le dépense que là où il prévient un vrai bug.Vol 3 followed a single idea across three issues: move the rules from your head into the type. The enum bounds the possible states; Option bounds absence; newtype and type-state bound your own invariants. At each step the move is the same — the illegal stops being something you check and becomes something you can't write. And the last issue set the limit: this power has a cost, spent only where it prevents a real bug.

L'outilThe toolCe qu'il rend impossibleWhat it makes impossibleLe réflexeThe reflex
enum + matchLes états absurdes ; l'oubli d'un cas à l'évolution.Absurd states; forgetting a case as code evolves.Cas exclusifs → enum exhaustif, pas de joker _ => métier.Exclusive cases → exhaustive enum, no domain _ => wildcard.
Option<T>Lire une valeur absente ; le null deref par surprise.Reading an absent value; the null deref by surprise.Peut manquer → Option ; propage avec ?, pas .unwrap() nu.May be missing → Option; propagate with ?, no bare .unwrap().
NewtypeNewtypeLa donnée invalide ; la confusion entre deux rôles.Invalid data; confusing two roles.Parse, don't validate : la garantie vit dans le type.Parse, don't validate: the guarantee lives in the type.
Type-stateType-stateL'opération illégale sur un état : la méthode n'existe pas.The illegal operation on a state: the method doesn't exist.Réserve-le aux machines à états où la transition illégale fait mal.Reserve it for state machines where an illegal transition hurts.
Concevoir en Rust, c'est se demander sans cesse : comment rendre ce bug impossible à écrire — puis vérifier que la réponse vaut son poids.Designing in Rust means asking, over and over: how do I make this bug impossible to write — then checking the answer is worth its weight.
Mot de l'éditeurFrom the editor

Trois volumes durant, le compilateur a dit non. Bientôt, il dira quoi faire.For three volumes, the compiler said no. Soon, it will say what to do.

Regarde le chemin parcouru. Le Vol 1 t'a appris à qui appartient la mémoire ; le Vol 2, combien de temps une vue peut la regarder ; le Vol 3, comment façonner des types où l'illégal n'a pas d'écriture. Trois volumes pour une même discipline : dire la vérité au compilateur, et le laisser refuser ce qui n'a pas de sens. Jusqu'ici, son rôle a surtout été d'interdire — pas de move illégal, pas de référence pendante, pas d'état absurde.Look at the path traveled. Vol 1 taught you who owns memory; Vol 2, how long a view may look at it; Vol 3, how to shape types where the illegal has no spelling. Three volumes for one discipline: tell the compiler the truth, and let it refuse what makes no sense. So far, its role has mostly been to forbid — no illegal move, no dangling reference, no absurd state.

Volume 4 : l'erreur comme valeur. On quitte la prévention pour la gestion. Toutes les erreurs ne sont pas des bugs à rendre impossibles — certaines sont des événements normaux : un fichier qui n'existe pas, un réseau qui tombe, une saisie invalide. Rust ne les traite pas par des exceptions qui sautent par-dessus ton code, mais par une valeur de retour, Result, que le compilateur t'oblige — comme Option — à regarder en face. L'échec cesse d'être une interruption surprise pour devenir une donnée comme une autre, qu'on transforme, propage et compose. Le même enum, encore, au cœur de la gestion d'erreur la plus honnête qui soit.Volume 4: the error as a value. We leave prevention for handling. Not every error is a bug to make impossible — some are normal events: a file that isn't there, a network that drops, invalid input. Rust handles them not with exceptions that leap over your code, but with a return value, Result, that the compiler forces you — like Option — to face. Failure stops being a surprise interruption and becomes data like any other, to transform, propagate and compose. The same enum, again, at the heart of the most honest error handling there is.