expect Is for Invariants Not Input
expect is for invariants, not input
The panics dimension of the Idiomatic Rust Rubric allows one exception to "no unwrap/expect in production": a documented infallible invariant, written as expect("why this can't fail"). The sharp line people miss is what can carry that exception. expect is legitimate for an invariant the program itself guarantees. It is never legitimate for data that came from outside the program.
The two kinds of "this can't fail"
- Program-controlled invariant — fine.
Regex::new(LITERAL).expect("valid regex")on a string literal you wrote;from_hms_opt(9, 0, 0).expect(...)on a hardcoded constant. The thing that would make it fail is in your source, so the compiler-adjacent reasoning holds. - Externally-sourced value — never. Config fields, CLI args, file contents, network responses, env vars. You don't control these, so "this can't fail" is a hope, not an invariant. The
expectis a deferred crash waiting for the input that proves you wrong.
The bug that names the rule
Mxr's snooze resolver did this:
let time = NaiveTime::from_hms_opt(hour, 0, 0).expect("validated snooze hour");
hour came from morning_hour: u8 in the user's TOML config. Nothing validated it — the "validated" in the message was a lie the author told themselves. A hand-edited morning_hour = 99 makes from_hms_opt return None, the expect panics, and because this runs in the daemon, the panic takes down background sync for every client. A one-character config typo, a process-wide outage.
The fix bounds the input before it reaches the panic, so the invariant becomes real:
// clamp at the boundary: a config value is input, not a guarantee
let time = NaiveTime::from_hms_opt(hour.min(23), 0, 0).expect("hour clamped to 0..=23");
Now the expect describes something the code actually enforces one line up. Returning a Result and surfacing a config error works too; the point is that the impossible value is handled, not asserted away.
The grep that finds them
Read your expect/unwrap messages. Any that say "validated", "should be valid", "checked", or "always" about data with an external origin is a latent panic — the message is doing the validation that the code skipped. The legitimate ones name a program-internal fact: a string literal, an exhaustive match, a value you constructed three lines above.
Prove the fix
A behaviour test feeds the impossible value and asserts no panic plus the clamped result — and it has teeth: delete the clamp and the test panics instead of asserting. That mutation-kill check is the difference between a real test and a decorative one. See Refactor Behind a Behavior Test.
See also
- Idiomatic Rust Rubric — the panics dimension this sharpens
- Carry Error Kinds Don't Re-derive Them — when the right answer is
Result, not a clamp - Refactor Behind a Behavior Test — proving a panic fix with a failing-first test