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

panic, unwrap & les invariants panic, unwrap & invariants

Un invariant brisé est un bug, pas une condition métier — et là, paniquer est correct. Contre-exemple : le unwrap partout pour faire passer le compilateur, l'exception non gérée qui revient. A broken invariant is a bug, not a business condition — and there, panicking is correct. Counter-example: unwrap everywhere to make the compiler pass, the unhandled exception returning.

AudienceAudience
Dev maîtrisant Result, ? et la conception d'erreurs (Vol 4 №1–2) Dev fluent in Result, ? and error design (Vol 4 №1–2)
Format
Self-paced
ChapitresChapters
5
Date
Juin 2026 Jun 2026
≈ 18 min ●●●○ ErreurspanicInvariants
SommaireContents ·
№ 01CadrageFraming3 min

Toutes les erreurs ne se gèrent pas. Certaines doivent tout arrêter.Not every error is to be handled. Some must stop everything.

Deux numéros ont martelé : ne panique pas, propage. On pourrait en conclure qu'un programme bien écrit ne panique jamais — ce serait l'erreur inverse du unwrap partout, mais une erreur quand même. La distinction qui ouvrait le volume revient ici pour le clore : une erreur attendue (fichier absent, réseau coupé, saisie invalide) appartient au Result ; un bug — un invariant qu'on s'était promis et qui se rompt — n'a rien à y faire. Quand un état déclaré impossible survient, continuer en faisant semblant est pire que s'arrêter : on propagerait des données corrompues. Là, paniquer n'est pas un échec de conception, c'est la conception.Two issues hammered: don't panic, propagate. You might conclude a well-written program never panics — that would be the mirror of unwrap-everywhere, but a mistake all the same. The distinction that opened the volume returns to close it: an expected error (missing file, dropped network, invalid input) belongs in a Result; a bug — an invariant you promised yourself that breaks — has no place there. When a state declared impossible occurs, carrying on pretending is worse than stopping: you'd propagate corrupted data. There, panicking isn't a design failure, it's the design.

La frontière — erreur attendue vs invariant briséThe border — expected error vs broken invariant
// ERREUR ATTENDUE — le monde réel, prévisible → Result
fn lire(chemin: &str) -> Result<String, io::Error> { /* le fichier peut manquer */ }

// BUG — un invariant que MON code s'était promis → panic
fn moyenne(notes: &[f64]) -> f64 {
    assert!(!notes.is_empty(), "moyenne() appelée sur une tranche vide");
    notes.iter().sum::<f64>() / notes.len() as f64   // len() == 0 serait une faute
}

// la frontière : « le monde extérieur peut-il produire ça normalement ? »
//   oui → Result.   non, c'est MA promesse qui se rompt → panic.
cette condition survient…this condition arises…
⌄
le monde extérieur la produit normalementthe outside world produces it normally
↓ attenduexpected
Result<T, E>
un invariant à MOI se romptan invariant of MINE breaks
↓ bugbug
panic! / assert!
Paniquer, c'est échouer fort et tôtTo panic is to fail loud and early

Un panic! déroule la pile (les destructeurs s'exécutent, la mémoire est libérée proprement) puis termine le thread — et, par défaut, le programme. Ce n'est pas un crash sauvage à la « segfault » : c'est un arrêt contrôlé, avec un message et, en debug, une trace. Tout l'enjeu est où on le déclenche. Paniquer au plus près de l'invariant rompu, c'est attraper le bug à sa source, avant qu'il ne contamine le reste. Le masquer en Result et le propager, c'est laisser le programme avancer sur une donnée déjà fausse — l'arrêt n'arrivera que plus loin, là où la cause est devenue illisible.A panic! unwinds the stack (destructors run, memory is freed cleanly) then ends the thread — and, by default, the program. It's not a wild 'segfault'-style crash: it's a controlled stop, with a message and, in debug, a trace. The whole question is where you trigger it. Panicking as close as possible to the broken invariant catches the bug at its source, before it contaminates the rest. Masking it as a Result and propagating it lets the program move on with already-wrong data — the stop comes only later, where the cause has become unreadable.

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

« Si cette condition survient, est-ce une situation que le monde produit normalement, ou la preuve que mon code a un bug ? » Normale → Result, on la gère. Bug → panic / assert, on s'arrête. Le critère n'est pas la gravité, mais l'origine : un disque plein est grave et reste un Result ; un index négatif est « anodin » et reste un bug.'If this condition arises, is it a situation the world produces normally, or proof my code has a bug?' Normal → Result, handle it. Bug → panic / assert, stop. The criterion isn't severity, but origin: a full disk is severe and stays a Result; a negative index is 'trivial' and stays a bug.

Un invariant, c'est une promesse.An invariant is a promise.

« Cette tranche n'est jamais vide ici », « cet index est toujours valide », « on n'atteint jamais cette branche ». Ce sont des promesses que ton code se fait à lui-même, qu'aucune entrée externe ne peut violer si le code est correct. Les rompre signifie donc une seule chose : le code est faux. Paniquer y est la réponse honnête — mieux vaut un arrêt net qu'un programme qui continue sur une hypothèse déjà fausse.'This slice is never empty here', 'this index is always valid', 'we never reach this branch'. These are promises your code makes to itself, that no external input can violate if the code is correct. Breaking them therefore means one thing: the code is wrong. Panicking is the honest answer — a clean stop beats a program running on an already-false assumption.

№ 02La surpriseThe surprise4 min

Un Result pour un bug pollue tous les appelants — et masque le vrai problème.A Result for a bug pollutes every caller — and hides the real problem.

Après deux numéros à fuir la panique, le réflexe devient excessif : on enveloppe tout en Result, y compris ce qui ne peut pas légitimement échouer. Le résultat est contre-intuitif. Renvoyer un Err pour un cas qui ne devrait jamais survenir oblige chaque appelant à gérer une branche fantôme — un cas qui n'arrive jamais, mais qu'on doit matcher partout, à jamais. Pire : le jour où ce cas survient quand même (parce qu'il y avait bien un bug), il ne fait pas de bruit ; il se propage comme une erreur ordinaire, et l'arrêt n'arrive que loin de la cause. Paniquer est ici le choix le plus sûr — il garde la signature honnête et fait éclater le bug à sa source.After two issues fleeing panic, the reflex turns excessive: you wrap everything in a Result, including what cannot legitimately fail. The result is counter-intuitive. Returning an Err for a case that should never happen forces every caller to handle a phantom branch — a case that never occurs, yet must be matched everywhere, forever. Worse: the day that case does occur (because there really was a bug), it makes no noise; it propagates like an ordinary error, and the stop comes only far from the cause. Panicking is the safer choice here — it keeps the signature honest and bursts the bug at its source.

Le contre-exemple — un Err pour un cas qui ne devrait jamais existerThe counter-example — an Err for a case that should never exist
// On déguise un BUG en erreur attendue : un Err « ça ne peut pas arriver »
enum FeuError { EtatImpossible }

fn suivant(feu: Feu) -> Result<Feu, FeuError> {
    match feu {
        Feu::Vert  => Ok(Feu::Orange),
        Feu::Orange => Ok(Feu::Rouge),
        Feu::Rouge => Ok(Feu::Vert),
        _ => Err(FeuError::EtatImpossible),   // une branche qui ne devrait JAMAIS exister
    }
}
// chaque appelant doit désormais gérer un cas qui n'arrive jamais —
// et le vrai bug (un Feu corrompu) remonte, masqué, jusqu'on ne sait où.

La branche _ => Err(...) a l'air prudente, elle est nuisible. Elle impose à tous de traiter un échec qui n'arrive jamais en fonctionnement correct, alourdissant chaque appel d'un cas mort. Et elle désamorce la sécurité du Vol 3 : tant que le _ est là, ajouter une variante à Feu ne casse plus la compilation — le compilateur ne peut plus t'avertir. On a échangé une vérification statique contre une erreur d'exécution silencieuse. C'est le pire des deux mondes.The _ => Err(...) branch looks cautious; it's harmful. It forces everyone to handle a failure that never happens in correct operation, weighing each call down with a dead case. And it defuses Vol 3's safety: as long as the _ is there, adding a variant to Feu no longer breaks compilation — the compiler can't warn you anymore. You traded a static check for a silent runtime error. It's the worst of both worlds.

Mettre un bug dans un Result, c'est demander à tout le monde de gérer ce qui ne doit jamais arriver — et de ne pas le voir quand ça arrive.Putting a bug in a Result asks everyone to handle what must never happen — and to miss it when it does.
№ 03La mécaniqueThe mechanics5 min

La trousse de l'arrêt : panic!, expect, unreachable!, assert!.The stopping toolkit: panic!, expect, unreachable!, assert!.

Paniquer n'est pas un seul geste mais une petite famille d'outils, chacun pour une nuance d'invariant. panic! est l'arrêt brut, avec message. unwrap et expect extraient un Option ou un Result en pariant sur le cas heureux — expect en documentant le pari, unwrap muet. unreachable! marque une branche qu'on a prouvée morte. assert! vérifie une condition à chaque exécution ; debug_assert! seulement en debug, donc gratuit en release. Tous mènent au même endroit — un arrêt contrôlé — mais ils diffèrent par ce qu'ils disent au prochain humain qui lira la trace. Le bon outil est celui qui nomme l'invariant supposé.Panicking isn't a single gesture but a small family of tools, each for a shade of invariant. panic! is the blunt stop, with a message. unwrap and expect extract an Option or a Result betting on the happy case — expect documenting the bet, unwrap mute. unreachable! marks a branch you've proven dead. assert! checks a condition on every run; debug_assert! only in debug, hence free in release. All lead to the same place — a controlled stop — but they differ in what they tell the next human reading the trace. The right tool is the one that names the assumed invariant.

La famille — chaque outil pour une nuance d'invariantThe family — each tool for a shade of invariant
panic!("invariant rompu: {x}");   // arrêt explicite, avec message

let v = opt.expect("la config est validée en amont, ceci ne peut être None");
let v = opt.unwrap();             // = expect, mais SANS dire pourquoi — à éviter

match etat {
    A | B => agir(),
    _ => unreachable!("etat ne peut valoir que A ou B ici"),  // branche prouvée morte
}

assert!(index < len, "index {index} hors borne (len {len})");   // toujours vérifié
debug_assert!(liste.est_triee());  // vérifié en debug, retiré en release (coût nul)
expect > unwrap : le message est un contratexpect > unwrap: the message is a contract
// expect bien rédigé = un contrat, pas une excuse
let port: u16 = config.port
    .expect("port absent: charger_config() garantit sa présence");
//        ^ le message dit l'INVARIANT qu'on suppose, pas « ça a planté ».
// si ça panique un jour, le message pointe la promesse exacte qui a cédé.

Quand paniquer est légitime, préfère expect à unwrap : non pour « faire propre », mais parce que le message documente l'invariant. Un bon message d'expect ne décrit pas l'échec (« était None ») — il énonce la promesse supposée (« la config est validée en amont »). Le jour où il panique, la trace ne dit pas seulement où ça a cassé, mais quelle hypothèse a cédé — ce qui transforme une heure de débogage en trente secondes.When panicking is legitimate, prefer expect to unwrap: not for 'tidiness', but because the message documents the invariant. A good expect message doesn't describe the failure ('was None') — it states the assumed promise ('config is validated upstream'). The day it panics, the trace says not just where it broke, but which assumption gave way — turning an hour of debugging into thirty seconds.

Déroulement ou abandonUnwind or abort

Par défaut, un panic déroule la pile : les destructeurs s'exécutent, les ressources se libèrent, et un thread qui panique ne tue que lui-même (son join renvoie un Err). On peut configurer panic = "abort" : le programme s'arrête immédiatement, sans dérouler — binaire plus petit, mais aucun nettoyage. Pour des cas très contrôlés, catch_unwind permet de rattraper un déroulement à une frontière (un serveur qui isole une requête fautive). Ce ne sont pas des exceptions déguisées : on ne s'en sert pas pour du flot de contrôle ordinaire.By default, a panic unwinds the stack: destructors run, resources free, and a panicking thread kills only itself (its join returns an Err). You can set panic = "abort": the program stops immediately, without unwinding — smaller binary, but no cleanup. For tightly controlled cases, catch_unwind can catch an unwind at a boundary (a server isolating a faulty request). These aren't exceptions in disguise: you don't use them for ordinary control flow.

assert! n'est pas de la validation.assert! is not validation.

Un assert! vérifie un invariant interne — une chose qui ne peut être fausse que si TON code a un bug. Il ne sert pas à valider une entrée externe : une saisie utilisateur invalide est une erreur attendue (Result), pas un bug à faire paniquer. La règle du chapitre suivant : ne jamais paniquer sur ce que l'appelant t'a donné, seulement sur ce que tu t'étais promis.An assert! checks an internal invariant — something that can only be false if YOUR code has a bug. It's not for validating external input: invalid user input is an expected error (Result), not a bug to panic on. The next chapter's rule: never panic on what the caller gave you, only on what you promised yourself.

Le bon outil d'arrêt n'est pas le plus court à écrire, c'est celui qui nomme l'invariant au prochain qui lira la trace.The right stopping tool isn't the shortest to write, it's the one that names the invariant to whoever next reads the trace.
№ 04Le piègeThe trap4 min

Ne fais jamais paniquer ton programme sur l'erreur de quelqu'un d'autre.Never panic your program on someone else's error.

Le piège le plus courant du panic n'est pas d'y recourir trop — c'est d'y recourir au mauvais endroit : sur l'entrée de l'appelant. Une bibliothèque qui panique parce qu'on lui a passé une chaîne malformée fait tomber le programme de son consommateur pour une faute qui n'est pas la sienne. Or une saisie invalide, un fichier absent, un format douteux venant de l'extérieur sont des erreurs attendues, pas des bugs : ils appartiennent au Result. La panique est réservée à tes invariants internes — les promesses que seul ton propre code peut rompre. La frontière du numéro se précise : paniquer sur ce que tu t'étais promis, jamais sur ce que le monde t'a envoyé.Panic's most common trap isn't using it too much — it's using it in the wrong place: on the caller's input. A library that panics because it was passed a malformed string brings down its consumer's program for a fault that isn't theirs. Yet invalid input, a missing file, a dubious format from outside are expected errors, not bugs: they belong in a Result. Panic is reserved for your internal invariants — the promises only your own code can break. The issue's border sharpens: panic on what you promised yourself, never on what the world sent you.

Le contre-exemple — une lib qui panique sur une chaîne malforméeThe counter-example — a lib that panics on a malformed string
// Une bibliothèque qui PANIQUE sur l'entrée de son appelant
pub fn parse_couleur(hex: &str) -> Couleur {
    assert!(hex.starts_with('#'), "format invalide");   // ✕ panique sur une saisie
    let n = u32::from_str_radix(&hex[1..], 16).unwrap(); // ✕ panique sur du texte
    Couleur::from_u32(n)
}

// l'appelant passe une chaîne venue d'un utilisateur. Si elle est malformée,
// TA lib fait tomber SON programme. Il ne peut ni rattraper, ni afficher
// un message propre : tu as transformé une erreur attendue en crash.

L'assert! et l'unwrap ici visent la mauvaise cible : l'entrée de l'appelant. Une couleur mal saisie est un événement parfaitement normal — l'utilisateur se trompe. En paniquant, la lib retire à son consommateur tout choix : pas de message clair, pas de nouvelle tentative, juste un crash pour une faute externe. C'est exactement le unwrap partout du n°1, mais aggravé : il est enfoui dans une dépendance, hors de portée de qui en subit les conséquences.The assert! and unwrap here aim at the wrong target: the caller's input. A mistyped color is a perfectly normal event — the user errs. By panicking, the lib strips its consumer of any choice: no clear message, no retry, just a crash for an external fault. It's exactly issue n°1's unwrap-everywhere, but worse: buried in a dependency, out of reach of whoever bears the consequences.

Le bon réflexe — Result vers l'extérieur, assert vers l'intérieurThe right reflex — Result outward, assert inward
// L'entrée externe → Result. L'invariant interne → assert.
pub fn parse_couleur(hex: &str) -> Result<Couleur, CouleurError> {
    let reste = hex.strip_prefix('#').ok_or(CouleurError::PasDeDiese)?;
    if reste.len() != 6 { return Err(CouleurError::MauvaiseLongueur); }  // ← la borne
    let n = u32::from_str_radix(reste, 16).map_err(|_| CouleurError::PasDuHex)?;
    Ok(Couleur::from_u32(n))   // ← ICI seulement, n est garanti dans [0, 0xFFFFFF] :
}                              //   6 chiffres hexa ne peuvent pas dépasser 0xFFFFFF.

impl Couleur {
    fn from_u32(n: u32) -> Couleur {
        debug_assert!(n <= 0xFFFFFF, "from_u32 reçoit un n hors plage : bug interne");
        // assert sur NOTRE invariant, pas sur l'entrée du monde extérieur.
        Couleur(n)
    }
}

L'entrée externe est traitée comme ce qu'elle est — faillible — et remonte en Result ; l'appelant décide. Noter le contrôle de longueur : c'est lui qui établit l'invariant, en bornant n à six chiffres hexadécimaux. Sans lui, #FFFFFFFF passerait le from_str_radix et ferait sauter l'assert interne — une saisie utilisateur déguisée en bug maison. Le debug_assert!, lui, garde un invariant interne : une fois la longueur validée, n est forcément dans la plage, et si from_u32 reçoit autre chose, c'est notre bug — pas celui de l'utilisateur. La même fonction abrite donc les deux régimes : Result à la frontière du monde, assert au cœur de nos propres garanties.External input is treated as what it is — fallible — and bubbles up as a Result; the caller decides. Note the length check: it is what establishes the invariant, by bounding n to six hex digits. Without it, #FFFFFFFF would sail through from_str_radix and trip the internal assert — user input disguised as a homegrown bug. The debug_assert!, in turn, guards an internal invariant: once the length is validated, n must be in range, and if from_u32 gets anything else, that's our bug — not the user's. The same function thus houses both regimes: Result at the world's boundary, assert at the heart of our own guarantees.

La règle de décisionThe decision rule

« D'où vient la donnée qui pourrait être invalide ? » De l'extérieur (appelant, fichier, réseau, utilisateur) → erreur attendue → Result. De l'intérieur, garantie par un code en amont qui ne peut faillir que sur un bug → invariant → assert / panic. La frontière entre Result et panic épouse exactement la frontière entre le monde et toi.'Where does the possibly-invalid data come from?' From outside (caller, file, network, user) → expected error → Result. From inside, guaranteed by upstream code that can only fail on a bug → invariant → assert / panic. The border between Result and panic traces exactly the border between the world and you.

Le coût en productionThe cost in production

Un panic en prod, c'est un thread (ou un service) qui tombe. Sur un serveur, une requête qui panique peut être isolée (catch_unwind à la frontière), mais une panique en boucle chaude, c'est une indisponibilité. D'où la discipline : panic pour les bugs, qu'on veut voir exploser en test et en CI, jamais comme stratégie de gestion d'erreur en exploitation. Si une condition peut survenir en prod sur des données réelles, ce n'était pas un invariant — c'était un Result qui s'ignorait.A panic in prod is a thread (or a service) going down. On a server, a panicking request can be isolated (catch_unwind at the boundary), but a panic in a hot loop is an outage. Hence the discipline: panic for bugs, which you want to blow up in tests and CI, never as an error-handling strategy in operation. If a condition can arise in prod on real data, it wasn't an invariant — it was a Result in denial.

Result à la frontière du monde, panic au cœur de tes garanties : la ligne entre les deux est celle entre le monde et toi.Result at the world's boundary, panic at the heart of your guarantees: the line between them is the line between the world and you.
№ 05Bilan & éditoWrap-up & editorial2 min

Une seule question sous tout le volume : attendu, ou bug ?One question beneath the whole volume: expected, or bug?

Le Vol 4 a suivi une seule ligne de partage à travers trois numéros. Le n°1 a posé le contenant : l'échec attendu est une valeur, Result, dans la signature, propagée par ?. Le n°2 l'a rempli : un type d'erreur qui dit ce qui a raté, précis pour une bibliothèque, agrégé pour une application. Le n°3 a tracé l'autre bord : le bug n'est pas un échec à gérer mais un invariant à faire éclater, par panic. À chaque étape, la même question décide de tout — cette condition, le monde la produit-il normalement, ou est-elle la preuve que mon code est faux ? Répondre juste, c'est savoir quoi propager et quoi arrêter.Vol 4 followed a single dividing line across three issues. Issue n°1 set the container: the expected failure is a value, Result, in the signature, propagated by ?. Issue n°2 filled it: an error type that says what went wrong, precise for a library, aggregated for an application. Issue n°3 drew the other edge: a bug isn't a failure to handle but an invariant to burst, via panic. At each step, the same question decides everything — does the world produce this condition normally, or is it proof my code is wrong? Answering it well is knowing what to propagate and what to stop.

L'outilThe toolCe qu'il installeWhat it sets upLe réflexeThe reflex
Result + ?L'échec attendu, dans le type, propagé sans bruit.The expected failure, in the type, propagated quietly.Échec normal du monde → Result, jamais une exception cachée.Normal world failure → Result, never a hidden exception.
thiserror / anyhowUne erreur qui dit ce qui a raté ; lib précise, app agrégée.An error that says what failed; lib precise, app aggregated.On voudra réagir → enum (lib) ; on rapporte → anyhow (app).Will react → enum (lib); will report → anyhow (app).
panic! / assert!L'arrêt net sur un invariant brisé — un bug, pas une condition.The clean stop on a broken invariant — a bug, not a condition.Ma promesse se rompt → panic ; jamais sur l'entrée d'autrui.My promise breaks → panic; never on someone else's input.
expect > unwrapLe message qui documente l'invariant supposé.The message that documents the assumed invariant.Si tu paries sur un cas, écris pourquoi il tient.If you bet on a case, write why it holds.
Gérer l'erreur en Rust, ce n'est pas l'attraper : c'est décider de son statut — valeur à propager, ou bug à faire éclater.Handling errors in Rust isn't catching them: it's deciding their status — a value to propagate, or a bug to burst.
Mot de l'éditeurFrom the editor

Quatre volumes sur la sûreté. Le cinquième ouvre la réutilisation.Four volumes on safety. The fifth opens reuse.

Regarde le chemin. 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 ; le Vol 4, comment traiter l'échec — attendu comme valeur, bug comme arrêt. Quatre volumes pour une même discipline : dire la vérité au compilateur, et le laisser refuser ce qui n'a pas de sens. Tu sais maintenant écrire du code Rust sûr. La question qui vient n'est plus la sûreté, mais le partage : comment écrire un comportement une fois et l'appliquer à mille types.Look at the path. 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; Vol 4, how to handle failure — expected as a value, bug as a stop. Four volumes for one discipline: tell the compiler the truth, and let it refuse what makes no sense. You can now write safe Rust. The next question is no longer safety, but sharing: how to write a behavior once and apply it to a thousand types.

Volume 5 : les traits, le polymorphisme sans héritage. Rust n'a pas de classes dont on hérite ; le partage de comportement passe par les traits — des contrats qu'un type décide d'honorer. Et le vrai arbitrage du volume sera celui entre deux façons de les résoudre : à la compilation, par les generics qu'on monomorphise (rapide, mais le binaire gonfle), ou à l'exécution, par les objets-trait dyn et leur vtable (flexible, au prix d'une indirection). Le même choix qui hante tout langage — statique contre dynamique — mais rendu explicite, et mis entre tes mains.Volume 5: traits, polymorphism without inheritance. Rust has no classes to inherit from; behavior sharing goes through traits — contracts a type chooses to honor. And the volume's real trade-off will be between two ways to resolve them: at compile time, via monomorphized generics (fast, but the binary grows), or at runtime, via dyn trait objects and their vtable (flexible, at the cost of an indirection). The same choice that haunts every language — static versus dynamic — but made explicit, and placed in your hands.