unwrap is a TODO you forgot to remove¶
Level: 201 · working knowledge
One line: Almost every unwrap in a codebase arrived by copy-paste from a crate's own front-page example, where it is deliberately a placeholder — the Rust Book says as much in Chapter 9 — and the reason yours are still there is that nothing in the default toolchain ever asks about them.
Three ways to panic¶
Chapter 9 names them, and they are worth being able to list, because the fix is different for each:
panic!on purpose — includingassert!,todo!,unimplemented!,unreachable!.- Panicking by accident — indexing past the end of a
Vec, slicing a&stroff a character boundary, integer overflow in a debug build, dividing by zero. - Unwrapping an
Error aNone—unwrap,expect, and every method that ends in them.
Only the third looks like a mistake. The second is the dangerous one, because v[i] does not read like a function that can abort your process.
Verified output of unwrap_is_a_todo.rs — regenerated by tools/run_examples.py, never hand-typed.
The three ways to panic (Rust Book, ch. 9) — and what to write instead
parse("41") -> Ok(41) unwrap_or_default() + 1 = 42
parse("nope") -> Err(invalid digit found in string) unwrap_or_default() + 1 = 1
v.get(1) -> Some(2)
v.get(99) -> None (v[99] would have panicked)
quorum(10, 3) -> Err("3 present, 6 needed")
quorum(10, 30) -> Ok(6)
The same work as a pipeline, with no unwrap anywhere:
"7" -> Ok(14)
"0" -> Err("zero has no reciprocal")
"not a number" -> Err("unreadable: invalid digit found in string")
Note the third replacement is not a combinator. panic! is removed by changing the signature — quorum returns a Result and the caller decides — which is why "just use unwrap_or" is not the whole answer.
Where yours actually came from¶
Here is the part that reframes the problem. Go and read the front page of the crates you use most:
serde's own README unwraps twice, in its headline example —to_string(&point).unwrap()andfrom_str(&serialized).unwrap(). So does the example on serde.rs.regex's README unwraps in its first example, on the veryRegex::newline, and again oncaptures.
These are not sloppy crates; they are two of the best-maintained in the ecosystem. And the Rust Book explicitly blesses it: in examples, an unwrap is understood to be "a placeholder for the way you'd want your application to handle errors" — the Book's words, Chapter 9.3 — precisely because full error handling would obscure the thing being demonstrated.
Which means the unwraps in your code mostly did not come from a decision. They came from a working example that you pasted, ran, and moved on from. The Book even names the mechanism approvingly: they are markers left for when you are ready to make the program robust.
They are todo!() with a return value. The problem is not that they exist — it is that a todo!() announces itself and an unwrap() compiles quietly, ships, and waits.
expect is the better marker¶
If you are going to leave one in, leave the one that says why you believed it could not fail:
When it does fire, the message is the assumption that turned out to be wrong, which is most of the debugging. This library argues that case at length on expect — and note that it is a different argument from this page. That page asks "given that this may panic, how should it panic?"; this one asks "should it panic at all?"
Make the tooling ask¶
Nothing in a default cargo build will ever mention any of this. Clippy will, once you tell it to — one block in Cargo.toml, covered in strict clippy lints:
[lints.clippy]
unwrap_used = "deny"
expect_used = "deny"
indexing_slicing = "deny" # catches category 2, the invisible one
panic = "deny"
Two things make this liveable rather than exhausting. Tests are exempt — allow-unwrap-in-tests = true in clippy.toml, and prototyping with unwrap inside a #[test] stays perfectly legal, which is where you should be prototyping anyway. And clippy suggests the replacement: index past the end and it says consider using .get(n), which is the combinator you wanted.
If you want proof rather than a lint, David Tolnay's no-panic ↗ crate fails the link step if an annotated function can reach the panic handler at all. It is stricter than clippy and, being a link-time trick, prone to false positives — a sharp tool for a function that genuinely must not abort, not a project-wide policy.
Practice¶
Delete four unwraps. Here is a config parser in the shape you get from copying a README — it works, and it panics on every one of the four ways the input can be wrong:
fn parse_quorum_panicking(line: &str) -> u32 {
let (key, value) = line.split_once('=').unwrap(); // 1. no '='
assert!(key == "quorum"); // 2. wrong key
let n: u32 = value.parse().unwrap(); // 3. not a number
assert!(n <= 100); // 4. out of range
n
}
Rewrite it so it returns a Result and no line can abort the process. Define an error enum naming the four cases — the caller should be able to tell "there was no =" from "900 is too large" without parsing a string. Then write the caller that does not care why and just wants a usable number.
Two things to try before opening the solution. Use a different technique for each unwrap: ok_or for the Option, a guard that returns for the assertion, ? with a From impl for the parse, and a plain if for the range — they are four different shapes and it is worth feeling that. And write it a second time as a single and_then chain, then decide which you would rather read at 5pm.
Solution
unwrap_is_a_todo_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: delete four unwraps from a config parser, one technique each.
//!
//! The starting version is the shape you get by copying a crate README: it
//! works on the happy path and panics on all four of the ways the input can be
//! wrong. Each rewrite below removes exactly one unwrap, and the last one is
//! the whole function as a pipeline.
//!
//! rustc --edition 2024 unwrap_is_a_todo_kata.rs -o /tmp/uiatk && /tmp/uiatk
use std::num::ParseIntError;
/// What a bad config line can be, named rather than panicked.
#[allow(dead_code)] // the fields are read only by {:?}, which dead-code analysis ignores
#[derive(Debug)]
enum ConfigError {
NoEquals,
UnknownKey(String),
BadNumber(ParseIntError),
OutOfRange { value: u32, max: u32 },
}
impl From<ParseIntError> for ConfigError {
fn from(e: ParseIntError) -> Self {
ConfigError::BadNumber(e)
}
}
/// The version that panics — kept only so the page can show what it replaces.
///
/// ```ignore
/// fn parse_quorum_panicking(line: &str) -> u32 {
/// let (key, value) = line.split_once('=').unwrap(); // 1. no '='
/// assert!(key == "quorum"); // 2. wrong key
/// let n: u32 = value.parse().unwrap(); // 3. not a number
/// assert!(n <= 100); // 4. out of range
/// n
/// }
/// ```
///
/// Every one of those four lines is a `todo!` somebody forgot to remove.
fn parse_quorum(line: &str) -> Result<u32, ConfigError> {
// 1. `ok_or` turns the Option from split_once into a Result.
let (key, value) = line.split_once('=').ok_or(ConfigError::NoEquals)?;
// 2. A guard that returns instead of asserting.
if key.trim() != "quorum" {
return Err(ConfigError::UnknownKey(key.trim().to_string()));
}
// 3. `?` plus the From impl above: ParseIntError becomes ConfigError.
let n: u32 = value.trim().parse()?;
// 4. The range check as a value, carrying both numbers.
if n > 100 {
return Err(ConfigError::OutOfRange { value: n, max: 100 });
}
Ok(n)
}
/// The same thing as one pipeline, for when every step is an expression.
fn parse_quorum_pipeline(line: &str) -> Result<u32, ConfigError> {
line.split_once('=')
.ok_or(ConfigError::NoEquals)
.and_then(|(key, value)| {
if key.trim() == "quorum" {
Ok(value)
} else {
Err(ConfigError::UnknownKey(key.trim().to_string()))
}
})
.and_then(|value| value.trim().parse::<u32>().map_err(ConfigError::BadNumber))
.and_then(|n| {
if n > 100 {
Err(ConfigError::OutOfRange { value: n, max: 100 })
} else {
Ok(n)
}
})
}
/// And the caller that does not care why, only what to use instead.
fn quorum_or_default(line: &str) -> u32 {
parse_quorum(line).unwrap_or(51)
}
fn main() {
let lines = [
"quorum=60",
"quorum = 7 ",
"quorum",
"seats=4",
"quorum=lots",
"quorum=900",
];
println!("{:<14} {:<44} {}", "input", "parse_quorum", "or_default");
println!("{}", "-".repeat(74));
for line in lines {
let got = parse_quorum(line);
// The two implementations must agree on every input; that is the test.
let pipeline = parse_quorum_pipeline(line);
assert_eq!(format!("{got:?}"), format!("{pipeline:?}"));
println!(
"{:<14} {:<44} {}",
format!("{line:?}"),
format!("{got:?}"),
quorum_or_default(line)
);
}
println!("\nboth implementations agreed on all {} inputs", lines.len());
}
Verified output of unwrap_is_a_todo_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
input parse_quorum or_default
--------------------------------------------------------------------------
"quorum=60" Ok(60) 60
"quorum = 7 " Ok(7) 7
"quorum" Err(NoEquals) 51
"seats=4" Err(UnknownKey("seats")) 51
"quorum=lots" Err(BadNumber(ParseIntError { kind: InvalidDigit })) 51
"quorum=900" Err(OutOfRange { value: 900, max: 100 }) 51
both implementations agreed on all 6 inputs
If you are coming from another language¶
- Python — the equivalent is a bare
except:that you meant to narrow, or the# TODO: handle thisabove a call you never came back to. The difference is that Python's version is a comment and Rust's is an expression, so a linter can see it and a compiler can be made to refuse it. - ABAP — closest to ignoring
sy-subrcafter aSELECT. Same shape: the language hands you the failure, taking it costs one line, and skipping it costs nothing until it costs everything. What Rust adds is that the failure is in the type, so skipping it takes a visible word.
See also¶
expect— the marker to use if you leave one, and why the message matters- What a panic costs — what actually happens when one of these fires
- Strict clippy lints — the configuration that makes the compiler ask
unwrap_or_default— the combinator that removes the most unwraps for the least thoughtanyhowand context — where the error goes once you stop unwrapping it- Tris Oaten, Rust: Don't Panic ↗ — the argument this page is built from
Po polsku¶
Ta strona nie mówi „nie używaj unwrap”, tylko wyjaśnia, skąd on się u ciebie wziął — a odpowiedź jest niewygodnie prozaiczna: z pierwszego przykładu na stronie crate'a. README serde rozpakowuje dwa razy w swoim sztandarowym przykładzie, README regex już w pierwszym, w linijce z Regex::new. To nie są niechlujne biblioteki, a Rust Book wprost taki zapis błogosławi: w przykładzie unwrap jest zastępnikiem miejsca, w którym twoja aplikacja obsłuży błąd po swojemu. Dla kogoś, kto czyta dokumentację w drugim języku, pokusa jest jeszcze silniejsza — wklejasz fragment, który działa, zamiast rekonstruować go z opisu — więc warto nazwać, co się właśnie stało: unwrap to todo!() z wartością zwracaną. Cała różnica polega na tym, że todo!() sam się zgłasza, a unwrap() kompiluje się po cichu, jedzie na produkcję i czeka.
Pomaga też przestać nazywać tę operację „rozpakowaniem”. unwrap niczego nie wyjmuje ze środka — on twierdzi, że wartość tam jest, i z góry zgadza się na to, że jeśli jej nie ma, program ma się przewrócić. Sposoby na panikę są trzy i każdy naprawia się inaczej:
- panika umyślna —
panic!,assert!,todo!,unimplemented!,unreachable!; - panika przypadkowa — indeks poza końcem
Vec, wycinek łańcucha ucięty w połowie znaku, przepełnienie liczby w kompilacjidebug, dzielenie przez zero; - rozpakowanie
ErralboNone—unwrap,expecti wszystko, co się nimi kończy.
Tylko trzecia wygląda na pomyłkę, a najgroźniejsza jest druga: v[i] nie wygląda na wywołanie zdolne ubić proces. I tu polski tekst zmienia rachunek prawdopodobieństwa, bo wycinek łańcucha (slice) tnie się po bajtach, a nie po znakach. Dla "żółw" len() zwraca 7, a chars().count() — 4, ponieważ ż, ó i ł zajmują po dwa bajty; &"żółw"[0..3] wywraca program komunikatem end byte index 3 is not a char boundary; it is inside 'ó' (bytes 2..4 of string). W tekście angielskim, czyli w czystym ASCII, identyczny kod przeżyje latami i nikt się nie dowie, że jest miną. Dlatego z listy lintów w Cargo.toml najważniejszy dla nas jest indexing_slicing = "deny" — pilnuje właśnie tej niewidocznej kategorii, a nie unwrapów, które i tak widać gołym okiem.
Na koniec dwie rzeczy warte zrobienia z tą wiedzą. Po pierwsze, usunięcie paniki nie zawsze polega na podmianie na kombinator: „użyj unwrap_or” jest tylko połową odpowiedzi, bo tam, gdzie stoi assert!, naprawą bywa zmiana sygnatury — funkcja zwraca Result, a decyzję podejmuje wywołujący. Po drugie, jeśli już coś zostawiasz, zostaw expect z powodem, dla którego uwierzyłeś, że to nie może zawieść — i zauważ, że jego treść może być po polsku, w odróżnieniu od Display: ten komunikat czyta programista, nie użytkownik. Resztę zleć narzędziom: cargo build nie odezwie się nigdy, clippy odezwie się po dopisaniu kilku wierszy do Cargo.toml, a allow-unwrap-in-tests = true zostawia prototypowanie z unwrapem w #[test] całkowicie legalnym — czyli dokładnie tam, gdzie i tak powinno się prototypować.
Szukaj po polsku: panika w Ruscie · polskie znaki a bajty w łańcuchu · rust unwrap in production · rust byte index is not a char boundary · clippy unwrap_used deny