The three ways to handle a value that might fail
You are writing a function that reads a configuration file. The file might be missing. A value inside might be the wrong type. The network request to fetch a default might time out. Rust forces you to confront these moments. You cannot ignore them. The compiler wraps uncertain values in containers: Option<T> for values that might not exist, and Result<T, E> for operations that might fail.
To get the value out of the container, you have three primary tools. unwrap extracts the value and crashes if it is missing. expect does the same but lets you attach a custom message to the crash. The ? operator extracts the value if it exists, but if it does not, it stops your function and returns the error to whoever called you. These tools look similar, but they signal completely different intentions. unwrap and expect say "this must succeed." The ? operator says "I can't handle this failure, so I'm passing it up."
The envelope analogy
Think of Option and Result as sealed envelopes. An Option envelope might contain a letter, or it might be empty. A Result envelope might contain a success letter, or it might contain an error report. You cannot read the letter without opening the envelope.
unwrap is a sledgehammer. You smash the envelope open. If there is a letter, you read it. If the envelope is empty, the hammer hits the table and your program panics. expect is the same sledgehammer, but you shout a warning before you swing. The warning helps anyone watching understand why the hammer hit the table.
The ? operator is a polite hand-off. You try to open the envelope. If there is a letter, you read it and continue. If the envelope is empty or contains an error, you hand the unopened envelope back to your caller and stop working. You are saying, "I cannot proceed without this value. You decide what to do."
Minimal examples
Here is how the syntax looks in practice. unwrap and expect work on both Option and Result. The ? operator also works on both, but it requires your function to return a compatible type.
use std::fs;
/// A function that returns a Result, enabling the ? operator.
/// If the file is missing, ? returns the error immediately.
fn read_config() -> Result<String, std::io::Error> {
// ? extracts the String if Ok.
// If Err, it returns the Err to the caller of read_config.
let contents = fs::read_to_string("config.txt")?;
Ok(contents)
}
fn main() {
// unwrap() on an Option.
// Extracts the value. Panics if None.
let number = Some(42).unwrap();
println!("Got number: {}", number);
// expect() on a Result.
// Extracts the value. Panics if Err with a custom message.
// This is preferred over unwrap when the context matters.
let data = fs::read_to_string("missing.txt")
.expect("Setup failed: config.txt is required");
}
unwrap and expect are identical under the hood. The only difference is the string you pass to expect becomes part of the panic message. The ? operator is different. It works at the type level. The compiler checks that your function returns a type that can hold the error you are propagating. If you use ? in a function that returns (), the compiler rejects it.
How the compiler expands ?
The ? operator is syntactic sugar. It expands to a match expression that checks the value. If the value is success, it extracts the inner data. If the value is failure, it returns early. This expansion explains why ? requires a compatible return type.
When you write let val = expr?;, the compiler desugars it roughly to this:
let val = match expr {
Ok(v) => v,
Err(e) => return Err(e.into()),
};
The .into() call is crucial. It allows the ? operator to convert error types automatically. If your function returns Result<T, MyError>, and expr returns Result<T, IoError>, the ? operator calls MyError::from(IoError) to convert the error. This conversion must exist. If the types do not match and no conversion is available, the compiler rejects the code.
This desugaring also shows why ? is zero-cost on the success path. It is just a check and an extraction. There is no allocation, no boxing, no overhead. The only cost is the check itself, which is unavoidable if you want safety.
Realistic usage: chaining operations
In real code, you often chain multiple operations that can fail. The ? operator shines here. It lets you write linear code without nesting match blocks or callbacks.
use std::fs;
use std::path::Path;
/// Reads a file and returns the first line.
/// Returns an error if the file is missing or empty.
fn first_line(path: &Path) -> Result<String, std::io::Error> {
// ? handles the file open error.
// If the file is missing, this returns Err immediately.
let mut file = fs::File::open(path)?;
let mut contents = String::new();
// ? handles read errors.
// If the disk fails, this returns Err immediately.
file.read_to_string(&mut contents)?;
// Split into lines and get the first one.
// unwrap() is acceptable here because we just read the string.
// If the string is empty, split returns an empty vec, and unwrap panics.
// This documents that an empty file is a hard error for this function.
let first = contents.lines().next().unwrap();
Ok(first.to_string())
}
The ? operator turns a cascade of potential failures into a single return path. Use it to keep your logic flat and readable.
Pitfalls and compiler errors
Using these tools incorrectly leads to common mistakes. The compiler catches most of them, but understanding the errors saves time.
Using ? in a function that returns nothing
If you use ? in a function that returns (), the compiler rejects it. The ? operator needs to return an error, but () cannot hold an error. You will see a mismatched types error.
fn bad_function() {
// Error: mismatched types
let _ = std::fs::read_to_string("x.txt")?;
}
The compiler reports E0308 (mismatched types). It expects the function to return (), but the ? operator tries to return a Result. Fix this by changing the return type to Result<(), std::io::Error> or by handling the error explicitly with match.
Type conversion failures
The ? operator relies on From conversions to adapt error types. If the error types do not match and no conversion exists, you get a trait bound error.
fn bad_conversion() -> Result<(), std::io::Error> {
let opt: Option<i32> = None;
// Error: cannot convert Option to Result
let _ = opt?;
}
The compiler reports E0277 (trait bound not satisfied). Option and Result are different types. You cannot propagate None as an Err without a conversion. In this case, you would need to map the Option to a Result first, or change the function to return Option.
Panic messages in production
Using unwrap in library code hides context. When the panic occurs, the stack trace shows the line number, but it does not explain why the value was expected to exist. This makes debugging harder for users of your library. The community convention is to use expect in libraries and public APIs. The message becomes part of your documentation for failure modes. unwrap is acceptable in main functions, unit tests, or when the code path is provably safe.
Treat expect as a promise to the caller. If you break it, the message is your apology.
Decision matrix
Choose the right tool based on what you want to communicate and how you want to handle failure.
Use unwrap in main functions or unit tests where the setup must succeed for the program to make sense. Use unwrap when you have just checked the condition explicitly, like after if opt.is_some(). Use unwrap when the alternative is truly impossible given the logic, and adding a message would be redundant.
Use expect in library code to document the invariant that causes the panic. Use expect when the error message helps the user fix their input or configuration. Use expect when you want to assert a condition that should never fail in production, but you want to provide context if it does.
Use the ? operator when your function returns a Result or Option and you want to propagate failures up the call stack. Use the ? operator to flatten nested error handling and keep your logic linear. Use the ? operator when the caller is better positioned to decide how to handle the error.
Reach for match or if let when you need to recover from an error or transform it into a different type. Reach for combinators like map and and_then when you want to chain operations without early returns.
Don't use unwrap to hide a problem. Use it to assert a fact.