Every language has to answer the same awkward question: what happens when a
function can't do what you asked? Rust's answer is unusually honest — errors
are ordinary values, carried in Result<T, E>
and Option<T>, and you handle them
with the same tools you use for everything else. The catch is that honesty is
verbose. Threading a possible failure up through five call frames by hand is
miserable.
The ? operator exists to make that
misery disappear without hiding the error. This article is a tour of what it
actually does.
The problem it solves
Here's error handling written out the long way — read a number from a string, double it, and hand back either the value or whatever went wrong:
fn double(input: &str) -> Result<i64, std::num::ParseIntError> {
let n = match input.trim().parse::<i64>() {
Ok(n) => n, // <- pull the value out on success
Err(e) => return e.into(), // <- ...or bail early on failure
};
Ok(n * 2)
}
That match is pure ceremony. It says nothing about doubling — it's all
plumbing. Now the same function with ?:
fn double(input: &str) -> Result<i64, std::num::ParseIntError> {
let n = input.trim().parse::<i64>()?; // <- unwrap-or-return, in one char
Ok(n * 2)
}
The ? reads as "give me the Ok value, or return the Err from this
function right now." The happy path stays front and center; the failure path
shrinks to a single glyph.
What ? actually desugars to
? is not magic — it's a well-defined rewrite. For a Result, expr?
expands to roughly:
match expr {
Ok(v) => v,
Err(e) => return Err(From::from(e)), // <- note the conversion!
}
Two things are worth pausing on:
- It returns from the enclosing function, not just the current expression.
?only makes sense inside a function that itself returns aResult(orOption, or another type implementingTry). - The error is passed through
From::frombefore being returned. That single conversion is what makes?compose across different error types — more on that below.
It works for Option too
The same operator threads None through a function that returns an
Option<T>:
fn first_char_upper(s: &str) -> Option<char> {
let c = s.chars().next()?; // <- None here returns None from the function
Some(c.to_ascii_uppercase())
}
Here expr? unwraps Some(v) to v, and on None returns None from the
function. You can't mix them implicitly — a ? on an Option in a function
that returns Result won't compile — which keeps the "what can this function
fail with?" contract visible in the signature.
The From conversion is the whole trick
Real programs fail in more than one way. A function that reads a file and
parses it can hit an I/O error
or a parse error. Because ? runs every error through From::from, you can
collect several underlying error types into one:
#[derive(Debug)]
enum ConfigError {
Io(std::io::Error),
Parse(std::num::ParseIntError),
}
impl From<std::io::Error> for ConfigError {
fn from(e: std::io::Error) -> Self { ConfigError::Io(e) }
}
impl From<std::num::ParseIntError> for ConfigError {
fn from(e: std::num::ParseIntError) -> Self { ConfigError::Parse(e) }
}
fn load_port(path: &str) -> Result<u16, ConfigError> {
let text = std::fs::read_to_string(path)?; // <- io::Error -> ConfigError
let port = text.trim().parse::<u16>()?; // <- ParseIntError -> ConfigError
Ok(port)
}
Both ? sites return the same ConfigError, each converting from its own
source error automatically. This is the standard shape of a
custom error type, and
it's why implementing From for your error enum pays for itself immediately.
When not to reach for it
? propagates; it does not handle. If the caller is the right place to make
a decision, don't push the error further up just because ? is convenient:
- At the top of the program, you eventually have to deal with the value.
mainitself can returnResult<(), E>, so?even works there — a returnedErris printed and sets a non-zero exit code. - When a failure is genuinely unrecoverable (a violated invariant, not bad
input), a panic is the
honest choice, not a
Resultyou never inspect.
The whole thing in one line
? is one character standing in for a match, an early return, and a From
conversion. Learn to read it as those three things and Rust's error handling
stops feeling verbose and starts feeling like what it is: ordinary values,
moved around with very little ceremony.
