The ? operator is Rust’s lightweight error-propagation syntax. It replaces the “let it bubble up” behavior you get for free with exceptions in TypeScript/JavaScript — but it does so explicitly, in the type system, with zero hidden control flow.
In TypeScript, a throw deep inside a call stack silently unwinds until something catches it; the function signatures say nothing about what can go wrong. In Rust there are no exceptions — fallible functions return a Result<T, E> (or an Option<T>), and the ? operator is the ergonomic way to say “if this is an Err/None, stop here and return it to my caller; otherwise give me the value inside.” Crucially, ? also runs an automatic From-based error conversion, so a function can collect several different underlying error types into one declared error type.
The call parsePort(env.PORT) has no syntactic marker that it might throw. The failure path is real but invisible, and the only place it becomes visible is the far-away try/catch.
If it is Ok(value), the whole expr? expression evaluates to value and execution continues.
If it is Err(e), the function returns early with Err(e.into()) — note the .into(), which performs the From conversion.
So this single character:
1
letport:u16=raw.parse()?;
is shorthand for this explicit match (verified equivalent):
1
usestd::num::ParseIntError;
2
3
fnmanual(s:&str)->Result<i32,ParseIntError>{
4
letn=matchs.parse::<i32>(){
5
Ok(v)=>v,
6
Err(e)=>returnErr(From::from(e)),// `?` inserts this From::from conversion
7
};
8
Ok(n*2)
9
}
Note: The early return is the key contrast with TypeScript. In TS, throw unwinds the stack through every frame until a catch. In Rust, ? returns from exactly one function — the one it appears in. To propagate further, the caller must also use ? (or otherwise handle the Result). Propagation is opt-in at every level.
The .into() that ? inserts is what lets a function unify different error types. In read_port, raw.parse() produces a Result<u16, ParseIntError>, but the function returns Result<u16, ConfigError>. Those error types differ. ? bridges the gap by calling ConfigError::from(parse_int_error), which exists because we implemented From<ParseIntError> for ConfigError.
This means a single ? does two jobs at once:
Propagate the error (early return on Err).
Convert it to the function’s declared error type.
If no suitable From impl exists, the code does not compile — see Common Pitfalls.
This is the closest Rust analogue to chaining JavaScript’s optional chaining (?.): a?.b?.c short-circuits to undefined on the first nullish hop, just as ? short-circuits to None on the first None.
1
usestd::collections::HashMap;
2
3
// Look up an outer key, then an inner key — short-circuit to None on the first miss.
4
fnnested_lookup<'a>(
5
data:&'aHashMap<String,HashMap<String,String>>,
6
outer:&str,
7
inner:&str,
8
)->Option<&'astr>{
9
letinner_map=data.get(outer)?;// None if `outer` missing
10
letvalue=inner_map.get(inner)?;// None if `inner` missing
11
Some(value.as_str())
12
}
Tip:? works in any function whose return type implements the Try machinery — in practice that means Result<T, E> and Option<T>. You cannot mix them implicitly: ? on a Result inside an Option-returning function will not compile. Convert between them with .ok() (Result → Option) or .ok_or(...) / .ok_or_else(...) (Option → Result), shown below.
main may itself return a Result, which lets you use ? at the top level. If main returns Err, the runtime prints the error’s Debug representation and exits with a non-zero status code:
letvalue=parse_config("oops")?;// propagates; main exits with Err
10
println!("value = {value}");
11
Ok(())
12
}
Real output (and the process exits with status 1):
1
Error: ParseIntError { kind: InvalidDigit }
Note:Box<dyn Error> is a trait object that can hold any error type, which is why ? can absorb both a ParseIntError and an std::io::Error in the same function — the standard library provides blanket From impls into Box<dyn Error>. This is the easy-mode error type for applications and main; see Box<dyn Error> and handling multiple errors.
The headline difference: in TypeScript the absence of error handling is the default and propagation is free; in Rust the presence of an error in the type is mandatory and propagation costs you one ?. This is more typing, but the compiler now guarantees you have not silently ignored a failure path.
Warning:? is not a try/catch. It does not handle an error — it forwards it. The actual handling (logging, retrying, mapping to an HTTP status) happens wherever you finally stop using ? and pattern-match the Result instead. Think of ? as the await-style ergonomics for the unhappy path, not as a recovery mechanism.
When the error type produced by ? cannot be converted into the function’s declared error type, the code does not compile. This is the most common surprise for people defining their own error enums:
1
#[derive(Debug)]
2
structMyError;
3
4
fnread_number(s:&str)->Result<i32,MyError>{
5
letn=s.parse::<i32>()?;// does not compile (error[E0277]): no From<ParseIntError> for MyError
6
Ok(n)
7
}
Real compiler error (trimmed):
1
error[E0277]: `?` couldn't convert the error to `MyError`
| -------------------- expected `MyError` because of this
6
5 | let n = s.parse::<i32>()?; // does not compile ...
7
| --------------^ the trait `From<ParseIntError>` is not implemented for `MyError`
8
...
9
note: `MyError` needs to implement `From<ParseIntError>`
10
= note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
The fix is to impl From<ParseIntError> for MyError, or — far more commonly in real code — derive it with thiserror’s #[from] attribute (see anyhow & thiserror). The compiler note spelling out “implicitly performs a conversion on the error value using the From trait” is your reminder that ? and From are inseparable.
A TypeScript developer sometimes reads value? as “try this and recover.” It does not recover — it forwards. If you want a fallback value instead of propagation, reach for unwrap_or, unwrap_or_else, unwrap_or_default, or a match. (unwrap/expect are a different, panicking story — see unwrap & expect.)
If a match arm’s only job is Err(e) => return Err(e.into()), replace the whole thing with ?. It is shorter, conventional, and makes the happy path readable top-to-bottom.
Let ? do your conversions — design error types with From in mind
The most ergonomic custom error types implement From for each underlying error they wrap, so that ? “just works” at every call site. Writing those impls by hand is tedious, so libraries should use thiserror’s #[from]:
1
// Sketch — see the anyhow & thiserror topic for the full, compile-verified version.
When you do not need to match on specific error variants (you just want to propagate and eventually log), Result<T, Box<dyn Error>> or anyhow::Result<T> lets ? absorb any error with no per-type From impls. Reserve precise enums for library APIs whose callers must distinguish failure modes. This application-vs-library split is covered in best practices.
? lets you write straight-line code where each step assumes the previous one succeeded. Lean into that: name intermediate values, keep one fallible operation per line, and let the early returns flatten what would otherwise be deeply nested error handling.
Use let ... else when you want to bail without a ?-compatible type
When you are pattern-matching an Option/Result and want to return (or continue/break) on the failing case with a custom action rather than propagation, let ... else is often clearer than forcing a ?:
A small but production-flavored configuration loader. It parses a KEY = VALUE block, collects two distinct error kinds (missing fields and bad numbers) into one error type, and uses ? at every fallible step. Note how ? short-circuits on the first failure encountered.
config error: field `port` is not a valid number: invalid digit found in string
3
config error: missing required field `max_connections`
The body of load_config reads like the happy path — host, then port, then max connections — and the ?s quietly guarantee that any failure stops and returns immediately. That is the same readability you get from await-laden async code, but for errors instead of asynchrony.
Note: The standard library does not provide a blanket From between two different concrete error types, but it does provide blanket conversions into Box<dyn Error>. So if you swap ConfigError for Box<dyn Error>, ? can absorb ParseIntError, std::io::Error, and friends in the same function with no hand-written From impls at all:
Verified: calling process("10", "/no/such/file") yields No such file or directory (os error 2), and process("ten", "/tmp") yields invalid digit found in string — two different error types, one ? each.
Objective: Use ? to propagate a parse error out of a loop.
Instructions: Write sum_all(inputs: &[&str]) -> Result<i32, ParseIntError> that parses each string to an i32 and returns their sum. The first unparseable string should make the whole function return its Err. Call it with ["1", "2", "3"] and with ["1", "x", "3"].
Objective: Use ? on Option to short-circuit a two-level lookup, mirroring TypeScript’s ?. chaining.
Instructions: Given a HashMap<String, HashMap<String, String>>, write nested_lookup(data, outer, inner) -> Option<&str> that returns the inner value if both keys exist, or None if either is missing. Use ? (not nested match). Test with an existing outer+inner pair, an existing outer with a missing inner, and a missing outer.
Objective: Make ? perform an automatic error conversion by implementing From, and combine it with an early-return for a second error variant.
Instructions: Define a CartError enum with two variants: Empty (the cart has no items) and BadQuantity(ParseIntError). Implement Display and From<ParseIntError> for CartError. Then write total_items(quantities: &[&str]) -> Result<u32, CartError> that returns CartError::Empty if the slice is empty, otherwise parses and sums the quantities using ? (relying on your From impl). Test with valid input, an unparseable entry, and an empty slice.