Error Propagation and Panics
File 01 introduced Result, ?, unwrap/expect. This file fills in the three
things you need before the applied modules (file I/O, CSV, JSON, HTTP): how ?
converts one error type into another, how Result and Option bridge, and how
panic! relates to exceptions.
? converts errors via From
? does more than "early-return on Err". When the error type of the failing
expression differs from the function's declared error type, ? calls
From::from on the error to convert it. Roughly, expr? desugars to:
use std::error::Error;
fn desugar(path: &str) -> Result<String, Box<dyn Error>> {
// `read_to_string` yields a `std::io::Error`, but this function returns
// `Box<dyn Error>`. `?` bridges the gap with `From::from`:
let text = match std::fs::read_to_string(path) {
Ok(value) => value,
Err(e) => return Err(From::from(e)), // <- the conversion `?` inserts
};
Ok(text)
}So ? works whenever there is a From<SourceError> for TargetError impl. The
standard library provides From<E> for Box<dyn Error> for every error type,
which is why Box<dyn std::error::Error> is the easy "accept any error" return
type for application code and main.
In C#: There is no direct equivalent. The closest is catching one exception
and rethrowing a wrapped one (throw new AppException("...", inner: e)), but in
C# you write that by hand at each call site. In Rust the conversion is automatic
at every ? as long as the From impl exists.
A worked example: two error types, one return type
Here a single function lifts both a std::num::ParseIntError and a
std::io::Error through the same ? operator, each converted into the same
Box<dyn Error>:
use std::error::Error;
fn read_and_double(path: &str) -> Result<i32, Box<dyn Error>> {
let text = std::fs::read_to_string(path)?; // std::io::Error -> Box<dyn Error>
let n: i32 = text.trim().parse()?; // ParseIntError -> Box<dyn Error>
Ok(n * 2)
}Both ? sites return early on failure; the caller receives a single
Box<dyn Error> and never has to know which concrete error occurred. When you
do want callers to distinguish variants, you define your own error enum and
implement From for each source error — module 27 shows how thiserror's
#[from] generates exactly those impls for you, and how anyhow::Error plays
the role of Box<dyn Error> with added context.
In C#: Like a method whose body can throw FormatException or IOException
and lets both bubble up to the caller — except in Rust the "can fail, and here is
the unified error type" fact is written in the signature.
main can return Result
main may return Result<(), E> for any E: Debug. On Ok(()) the process
exits 0; on Err(e) Rust prints the error with its Debug representation and
exits with a non-zero code. This lets you use ? directly in main instead of
unwrapping:
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let n: i32 = "42".parse()?; // no match / unwrap needed
println!("n = {}", n);
Ok(())
}In C#: Comparable to static async Task<int> Main returning an exit code, or
to letting an exception escape Main so the runtime prints it and returns a
non-zero code — but here it is an explicit, typed return value, not an unhandled
throw.
Bridging Option and Result
? also works on Option inside a function returning Option, where it
early-returns None. But it does not auto-convert between Option and
Result. To move between them, convert explicitly:
Option<T> -> Result<T, E>: use.ok_or(err)or.ok_or_else(|| err).Result<T, E> -> Option<T>: use.ok()(discards the error) or.err().
use std::error::Error;
fn first_word(s: &str) -> Result<&str, Box<dyn Error>> {
// `next()` returns Option; turn `None` into an error, then `?` it.
let word = s.split_whitespace().next().ok_or("empty input")?;
Ok(word)
}
fn try_parse(s: &str) -> Option<i32> {
// `parse()` returns Result; drop the error to get an Option, then `?` it.
let n: i32 = s.parse().ok()?;
Some(n)
}(The string literal "empty input" becomes the error because there is a
From<&str> for Box<dyn Error> impl — the ? conversion rule from above.)
In C#: .ok_or(...) is like turning a null/bool TryGet miss into a thrown
exception; .ok() is like swallowing an exception in a try/catch and returning
a nullable instead.
Panic vs exception: the bridge
This is the mental model C# developers most need:
| Rust | What it is | C# analogy |
|---|---|---|
Result<T, E> returned + handled (match) or propagated (?) | A recoverable error you expect and deal with | An exception you catch (or deliberately let bubble) |
panic!, unwrap(), expect() | An unrecoverable bug; terminates (by default, unwinds) the current thread | An unhandled exception that crashes the program |
Key differences, stated precisely:
- There is no
try/catchfor control flow. You handle recoverable errors by matching onResultor propagating with?. (A functioncatch_unwindexists, but it is for stopping an unwinding panic at an FFI or thread boundary, not for ordinary error handling — do not reach for it as acatch.) ?has no unwind cost. It compiles to a branch and an earlyreturn; it does not unwind the stack or build a stack trace. C# exceptions, by contrast, pay stack-unwinding and stack-capture cost when thrown. So in Rust the cheap, common path is the explicit one.- A
panic!unwinds (or aborts) the thread. By default it unwinds, running destructors, prints a message to stderr, and — if it reaches the top of the main thread — exits the process with a failure code. It is meant for "this should never happen" bugs (broken invariants, indexing out of bounds), the way an unhandled exception signals a defect. - The error type is part of the signature — a compile-time contract.
fn load() -> Result<Config, io::Error>tells every caller, checked by the compiler, exactly how it can fail. In C# the throwable exceptions are not in the signature (C# has no checked exceptions), so the compiler cannot force you to handle or declare them; you learn what a method throws from docs or by reading its body.
In C#: Reserve unwrap/expect/panic! for the situations where you would
let an exception go unhandled because continuing would be a bug. Use Result +
? everywhere you would otherwise write a try/catch or a bool TryX(out ...).
Key takeaway
?early-returns onErrand callsFrom::fromto convert the error into the function's declared error type;Box<dyn std::error::Error>accepts them all, so one?can lift many different error types.maincan returnResult<(), E>, so you can use?at the top level.- Convert between
OptionandResultexplicitly:.ok_or(...)/.ok_or_else(...)and.ok(). Result= a recoverable error youmatchor?;panic!/unwrap/expect= an unhandled-exception-style crash. There is notry/catch,?has no unwind cost, and the error type is a compile-time part of the signature.