12.4Handling invalid input properly
Lesson 7.10 left an IOU. The guessing game crashed when you typed banana instead of a number, because parse().expect(...) panics on bad input, and we promised chapter 12 would turn that crash into a polite retry. The promise comes due. This lesson uses Result and the pattern-matching tools to handle user input the way real programs do: by anticipating every way it can go wrong and responding, not crashing.
Why input is the classic recoverable error
User input is the textbook recoverable failure. People mistype, paste the wrong thing, hit enter by accident. None of that is a bug in your program, so none of it should panic. A program that crashes because someone fat-fingered a digit is a badly behaved program. The right response is almost always "tell them what went wrong and let them try again," and Result is what makes that response systematic instead of a pile of ad-hoc checks.
The ways a single line of typed input can be wrong form a small taxonomy worth naming. It can be malformed: not the kind of thing you asked for at all (banana when you wanted a number). It can be out of range: the right kind but an unacceptable value (500 when you wanted 1 to 100). And the parse can encounter extraction failure: reading the line itself fails, or there's nothing there. Each is a distinct case, and good input handling addresses all of them.
From crash to retry
Here's the guessing game's input step as lesson 7.10 left it, the version that crashes:
let guess: u32 = guess.trim().parse().expect("Please type a number!");
parse returns a Result (lesson 12.2), and expect turns any Err into a panic (lesson 12.1). To recover instead of crash, replace the expect with a match that handles the Err by skipping the bad input and looping again. The Rust Book's exact fix:
use std::io;
fn main() {
println!("Guess the number (1-100):");
loop {
let mut guess = String::new();
io::stdin().read_line(&mut guess).expect("failed to read line");
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("That wasn't a number. Try again.");
continue;
}
};
println!("You guessed: {guess}");
break;
}
}Guess the number (1-100):
banana
That wasn't a number. Try again.
42
You guessed: 42
The whole transformation is in that match. On Ok(num), we keep the number and carry on. On Err(_), instead of panicking, we print a friendly message and continue (lesson 7.6) back to the top of the loop to ask again. Typing banana no longer ends the program; it costs the user one polite reminder. This is the difference between handling an error and exploding on it, and it's two lines of difference. (The expect on read_line stays, because a failure to read stdin at all is a genuinely unusual situation, not ordinary bad input; lesson 12.5 sharpens that judgment.)
Handling the out-of-range case too
That match handles malformed input. The out of range case (a number, but not 1 to 100) is a separate check, and it's where the Ok arm earns a guard or a follow-up test:
let guess: u32 = match guess.trim().parse() {
Ok(num) if (1..=100).contains(&num) => num,
Ok(_) => {
println!("Please enter a number from 1 to 100.");
continue;
}
Err(_) => {
println!("That wasn't a number. Try again.");
continue;
}
};
Now three outcomes are handled distinctly: a valid in-range number passes through, an out-of-range number gets its own message, and non-numbers get theirs. The guard if (1..=100).contains(&num) (a range method that tests membership, lesson 7.5's ranges with a method on top) splits the two Ok cases. This is the taxonomy turned into code: each way the input can be wrong gets its own arm and its own message, which is exactly what a user wants, a specific reason rather than a generic rejection.
Best practice
Validate input as close to where you read it as possible, and give each failure its own message. "That wasn't a number" and "that's out of range" are different problems and deserve different responses; collapsing them into one "invalid input" wastes the information you have. The match-on-parse with guards is the standard shape for this, and continue in a loop is the standard "let them retry."
Factoring it into a function
As the validation grows, it wants to be its own function, and now Result and ? (lesson 12.3) pay off. A function that reads and validates one guess, returning a Result, keeps main's loop clean:
use std::io;
fn read_guess() -> Result<u32, String> {
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.map_err(|_| String::from("could not read input"))?;
let num: u32 = input
.trim()
.parse()
.map_err(|_| String::from("not a number"))?;
if (1..=100).contains(&num) {
Ok(num)
} else {
Err(String::from("out of range (1-100)"))
}
}
fn main() {
loop {
match read_guess() {
Ok(guess) => {
println!("You guessed: {guess}");
break;
}
Err(reason) => println!("{reason}, try again."),
}
}
}You guessed: 42
read_guess returns Result<u32, String>: a valid guess or a reason it failed. The two ?s propagate read and parse failures, converting each error into a readable String with map_err (a method that transforms the error inside an Err, leaving an Ok untouched; the |_| ... is a closure, chapter 19's subject, here just "ignore the original error and use this message"). main calls it in a loop and decides what to do with each outcome. The validation logic lives in one place, main reads cleanly, and every failure mode is still handled. This is the shape real input handling takes once it outgrows a single match.
Quiz time
Question #1
What single change turns parse().expect("...") from a crash-on-bad-input into a retry, and what does that change rely on?
Show solution
Replace the expect with a match on the Result: keep the value on Ok, and on Err print a message and continue the loop instead of panicking. It relies on parse returning a Result (lesson 12.2), so the failure is a value you can branch on rather than an explosion.
Question #2
Name the three categories in the input-failure taxonomy, with an example of each for "enter a number from 1 to 100."
Show solution
Malformed (not the right kind of thing): banana. Out of range (right kind, bad value): 500. Extraction failure (couldn't read or nothing there): the read itself fails, or empty input. Good handling gives each its own response.
Question #3
Why does the example keep .expect("failed to read line") on read_line while replacing expect on parse?
Show solution
A parse failure is ordinary, expected bad input (the user typed letters), so it deserves recovery, not a panic. A read_line failure means reading from standard input itself broke, which is rare and not something a retry loop can sensibly fix in a simple program, so panicking (or propagating) is defensible. Lesson 12.5 makes this "expected versus exceptional" judgment the explicit rule.
That last quiz answer is the whole next lesson: how do you decide whether a given failure deserves a Result or a panic!? It comes down to a few clear guidelines, and getting it right is what separates code that crashes rudely from code that fails gracefully.