Result & the Question Mark
Rust does not have exceptions. There is no try-catch, no invisible control flow, no wondering whether a function might throw. Instead, operations that can fail return Result<T, E>. The ? operator propagates errors up the call stack cleanly. This approach is more explicit, more composable, and produces more reliable software than exception-based error handling.
Result<T, E> Everywhere
Result is an enum with two variants:
enum Result<T, E> {
Ok(T), // success, carrying the value
Err(E), // failure, carrying the error
}
Every fallible operation in the standard library returns Result:
use std::fs;
use std::net::TcpListener;
fn main() {
// File I/O returns Result<String, io::Error>
let contents = fs::read_to_string("/etc/hostname");
match contents {
Ok(text) => println!("Hostname: {}", text.trim()),
Err(e) => println!("Cannot read hostname: {}", e),
}
// Parsing returns Result<T, ParseIntError>
let num: Result<i32, _> = "42".parse();
match num {
Ok(n) => println!("Parsed: {}", n),
Err(e) => println!("Parse error: {}", e),
}
// Network operations return Result
match TcpListener::bind("127.0.0.1:0") {
Ok(listener) => println!("Listening on {:?}", listener.local_addr().unwrap()),
Err(e) => println!("Bind failed: {}", e),
}
}
Hostname: myhost
Parsed: 42
Listening on 127.0.0.1:54321
The ? Operator
Matching on every Result gets verbose fast. The ? operator propagates errors automatically — if the value is Ok, it unwraps it; if it is Err, it returns the error from the current function:
use std::fs;
use std::io;
fn read_username() -> Result<String, io::Error> {
let contents = fs::read_to_string("/etc/passwd")?; // returns Err if this fails
let first_line = contents.lines().next().unwrap_or("");
let username = first_line.split(':').next().unwrap_or("unknown");
Ok(username.to_string())
}
fn main() {
match read_username() {
Ok(name) => println!("First user: {}", name),
Err(e) => println!("Error: {}", e),
}
}
Without ?, you would write:
fn read_username_verbose() -> Result<String, io::Error> {
let contents = match fs::read_to_string("/etc/passwd") {
Ok(c) => c,
Err(e) => return Err(e),
};
let first_line = contents.lines().next().unwrap_or("");
let username = first_line.split(':').next().unwrap_or("unknown");
Ok(username.to_string())
}
? replaces four lines with one. It composes. You can chain multiple fallible operations:
use std::fs;
use std::io;
use std::path::Path;
fn load_config(path: &Path) -> Result<String, io::Error> {
let raw = fs::read_to_string(path)?;
let trimmed = raw.trim().to_string();
Ok(trimmed)
}
fn setup_app() -> Result<(), io::Error> {
let config = load_config(Path::new("/etc/myapp/config.toml"))?;
let port = load_config(Path::new("/etc/myapp/port"))?;
println!("Config: {}, Port: {}", config, port);
Ok(())
}
Each ? is an early return on error. The happy path reads top to bottom without indentation.
Mapping Errors with map_err
When the error types do not match, map_err converts one error type to another:
use std::fs;
use std::num::ParseIntError;
#[derive(Debug)]
enum AppError {
Io(std::io::Error),
Parse(ParseIntError),
Config(String),
}
fn read_port() -> Result<u16, AppError> {
let contents = fs::read_to_string("/etc/myapp/port")
.map_err(AppError::Io)?;
let port = contents.trim().parse::<u16>()
.map_err(AppError::Parse)?;
if port < 1024 {
return Err(AppError::Config(format!("port {} is privileged", port)));
}
Ok(port)
}
fn main() {
match read_port() {
Ok(port) => println!("Using port {}", port),
Err(e) => println!("Error: {:?}", e),
}
}
Error: Io(Os { code: 2, kind: NotFound, message: "No such file or directory" })
map_err converts the error type without affecting the success case.
Chaining with and_then
and_then chains operations where each step can fail:
fn parse_and_double(input: &str) -> Result<i64, String> {
input
.trim()
.parse::<i64>()
.map_err(|e| format!("parse error: {}", e))
.and_then(|n| {
n.checked_mul(2)
.ok_or_else(|| format!("overflow when doubling {}", n))
})
}
fn main() {
let inputs = ["21", "abc", "4611686018427387904", " 42 "];
for input in inputs {
match parse_and_double(input) {
Ok(result) => println!("'{}' -> {}", input.trim(), result),
Err(e) => println!("'{}' -> error: {}", input.trim(), e),
}
}
}
'21' -> 42
'abc' -> error: parse error: invalid digit found in string
'4611686018427387904' -> error: overflow when doubling 4611686018427387904
'42' -> 84
unwrap & expect
unwrap() extracts the value from Ok or panics on Err. expect() does the same but with a custom panic message.
fn main() {
// Fine in tests and prototypes:
let port: u16 = "8080".parse().unwrap();
println!("Port: {}", port);
// Better — the message tells you what went wrong:
let port: u16 = "8080".parse()
.expect("PORT must be a valid u16");
println!("Port: {}", port);
}
Port: 8080
Port: 8080
When unwrap is acceptable:
- Tests — a panic is a test failure, which is what you want
- Prototyping — you are iterating fast and will handle errors later
- When you have already validated the input and the error is logically impossible
When unwrap will ruin your day:
- Production code handling user input
- Anything touching the filesystem or network
- Parsing external data
// This will crash your production server at 3 AM:
fn handle_request(body: &str) {
let value: serde_json::Value = serde_json::from_str(body).unwrap();
// If the body is malformed, the whole server panics
println!("{}", value);
}
// This keeps your server running:
fn handle_request_safely(body: &str) -> Result<(), String> {
let value: serde_json::Value = serde_json::from_str(body)
.map_err(|e| format!("invalid JSON: {}", e))?;
println!("{}", value);
Ok(())
}
Using ? in main
main can return a Result:
use std::fs;
use std::io;
fn main() -> Result<(), io::Error> {
let config = fs::read_to_string("config.toml")?;
let port: u16 = config.trim().parse()
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
println!("Starting on port {}", port);
Ok(())
}
When main returns Err, the program exits with a non-zero status code and prints the error using its Debug implementation.
Collecting Results
When you have a collection of results, collect can gather them into a single Result:
fn main() {
let inputs = vec!["1", "2", "three", "4"];
// Collect into Result<Vec<i32>, _> — stops at first error:
let result: Result<Vec<i32>, _> = inputs.iter()
.map(|s| s.parse::<i32>())
.collect();
match result {
Ok(numbers) => println!("All parsed: {:?}", numbers),
Err(e) => println!("Failed: {}", e),
}
// Partition into successes and failures:
let (successes, failures): (Vec<_>, Vec<_>) = inputs.iter()
.map(|s| s.parse::<i32>())
.partition(Result::is_ok);
let numbers: Vec<i32> = successes.into_iter().map(Result::unwrap).collect();
println!("Parsed {} values, {} failed", numbers.len(), failures.len());
}
Failed: invalid digit found in string
Parsed 3 values, 1 failed
Common Pitfalls
- Using unwrap in library code. Libraries should never panic on behalf of their callers. Always return
ResultorOption. - Discarding error context.
file.read()?.parse()?loses information about which step failed. Add context withmap_err. - Returning
Stringas the error type. It works but you lose the ability to programmatically match on error kinds. Use proper error types oranyhowfor applications. - Ignoring
Resultreturn values. The compiler warns about unusedResultvalues. Do not suppress the warning — handle the error. - Mixing
?withunwrapin the same function. Pick one style. If the function returnsResult, use?. If it is a test,unwrapis fine. - Not realizing
?performsFromconversion. If your function returnsResult<T, MyError>and you use?on aResult<T, io::Error>, Rust will callMyError::from(io::Error)automatically. ImplementFromto enable this.
Key Takeaways
Result<T, E>makes errors visible in function signatures. No hidden throws.- The
?operator propagates errors concisely. It replaces verbose match statements and keeps the happy path readable. map_errconverts error types.and_thenchains fallible operations.unwrapandexpectare for tests and prototypes. In production, handle errors explicitly.?automatically callsFromfor error type conversion, making it composable across different error types.- Errors are values, not exceptions. You can store them, transform them, collect them, and return them.