Error Handling in Rust with Result and Option
I walk through Rust's Result<T,E> and Option<T> types, the ? operator, pattern matching on error variants, and custom error types with the thiserror crate.

In TypeScript, a function’s signature tells you what it returns when things go well. What it throws — and the shape of that error — isn’t in the contract. You find out from documentation, from reading the source, or from an unhandled exception crashing a request at 2 a.m.
Rust made a different call. If a function can fail, the return type says so. Result<T, E> for failure that callers should handle. Option<T> for values that might not exist. The compiler won’t let you ignore either one. Once that clicked for me, the model started to feel less like restriction and more like information I’d always wanted.
Here’s what it looks like in practice — covering Result<T, E>, Option<T>, the ? operator, pattern matching on error variants, and custom error types with thiserror.
Series: Part 9 of 12. Start with Part 1: Learning Rust as a TypeScript Developer · Previous: Understanding Cargo and Rust Project Structure
On this page
- Two kinds of failure: panic! and Result
- Result<T, E>: errors as values, not exceptions
- The ? operator: propagating errors without the ceremony
- Option<T>: the absence type TypeScript never quite had
- Custom error types with thiserror
- When to panic and when to return a Result
- Errors as data change how you write code
Two kinds of failure: panic! and Result
Rust separates failures into two categories, and the line between them matters.
A panic! is for failures the program can’t recover from — a bug, a violated invariant, an out-of-bounds index. When a panic fires, execution stops. The thread unwinds. There’s no catching it and continuing:
fn main() {
let scores = vec![10, 20, 30];
println!("{}", scores[99]); // panics: index out of bounds
}
TypeScript throws for everything — parse failures, network errors, assertion violations, index out of bounds. They all land in catch (err) and you decide from there.
Rust draws the line earlier. panic! is for conditions that indicate the code itself is wrong. Result<T, E> is for failures that are expected parts of normal operation: a file that might not exist, a network request that might time out, user input that might not parse. These aren’t bugs; they’re states the program should handle.
That distinction shapes how errors propagate and how callers respond to them.
Result<T, E>: errors as values, not exceptions
Result is an enum in the standard library:
enum Result<T, E> {
Ok(T),
Err(E),
}
T is the type of the success value. E is the type of the error. When you open a file, the return type is Result<File, io::Error>. At the call site, both outcomes are visible before you run anything.
The most explicit way to handle a Result is pattern matching:
use std::fs::File;
fn load_config(path: &str) -> String {
let result = File::open(path);
let file = match result {
Ok(f) => f,
Err(e) => {
eprintln!("Could not open {path}: {e}");
return String::new();
}
};
// read from file...
String::new()
}
Compare the TypeScript equivalent:
function loadConfig(path: string): string {
try {
const data = fs.readFileSync(path, "utf-8");
return data;
} catch (err) {
// err is `unknown` — you must narrow the type yourself
console.error(`Could not open ${path}:`, err);
return "";
}
}
In TypeScript’s catch, the error is typed as unknown in strict mode. You have to cast and check the type yourself before doing anything meaningful with it. In Rust, the match arm gives you an io::Error directly — you know what you’re working with.
For prototyping and scripts, there are shortcuts. unwrap() returns the value inside Ok or panics on Err. expect("message") panics with your custom text:
let config = File::open("settings.toml")
.expect("settings.toml must be present at startup");
expect is fine in tests and early code. In a production library or anything with untrusted input, you want to propagate the error so callers can decide what to do.
Check this before moving on
- You can match on
Ok(value)andErr(e)in amatchexpression without extra imports — both variants are in the prelude - You understand that
unwrap()in production code is a panic waiting to happen - You know that
EinResult<T, E>is fixed at the function’s return type — the caller sees exactly what error shape to expect
The ? operator: propagating errors without the ceremony
Writing a match around every fallible call gets noisy fast. The ? operator handles the common pattern: if the value is Ok, unwrap it and continue; if it’s Err, return that error early from the current function.
use std::fs;
use std::io;
fn read_config() -> Result<String, io::Error> {
let contents = fs::read_to_string("settings.toml")?;
Ok(contents)
}
Without ?, this function needs a match around fs::read_to_string. With ?, the error propagates automatically and the happy path is readable.
You can chain ? calls across multiple fallible operations:
use std::fs::File;
use std::io::{self, Read};
fn read_username(path: &str) -> Result<String, io::Error> {
let mut username = String::new();
File::open(path)?.read_to_string(&mut username)?;
Ok(username.trim().to_string())
}
Each ? either extracts the inner value or returns early with the error. There’s also a subtlety worth knowing: ? calls From::from on the error before returning it. That means it can automatically convert between compatible error types — useful when your function’s return type uses a broader error than what the underlying call returns. I’ll come back to this when we get to custom error types.
One hard constraint: ? only works inside functions that return Result or Option. Try to use it elsewhere and the compiler will tell you, with a clear message suggesting how to fix the return type.
Try this
- Create a new Rust project with
cargo new error-demo.- In
src/main.rs, write a functionfn first_line(path: &str) -> Result<String, std::io::Error>that reads a file and returns the first line using?.- Call it from
main, match on the result, and print either the line or the error.Expected result: If the file exists, you’ll see the first line. If it doesn’t, you’ll see an
io::Errorthat names the missing file — and the program won’t crash.
Option<T>: the absence type TypeScript never quite had
Option<T> is for values that might not be there:
enum Option<T> {
Some(T),
None,
}
TypeScript reaches for null, undefined, or union types like string | null to express the same idea. The problem is that null and undefined are assignment-compatible with other types in many contexts, especially across library boundaries. You can pass null where a non-null value is expected and not find out until runtime.
In Rust, if a function returns Option<String>, you can’t use it as a plain String until you’ve handled the None case. The compiler enforces the distinction.
The standard library gives you good ways to work with Option without writing a match every time:
let name: Option<&str> = Some("Soumitra");
// Use a fallback value when None
let display = name.unwrap_or("anonymous");
// Transform the inner value; leave None alone
let upper = name.map(|s| s.to_uppercase());
// Chain operations that each return Option
let first_char: Option<char> = name.and_then(|s| s.chars().next());
The ? operator works on Option too, in functions that return Option. If the value is None, it returns None early from the function — the same early-return semantics as with Result:
fn first_char_of_first_line(text: &str) -> Option<char> {
text.lines().next()?.chars().last()
}
One thing to keep in mind: you can’t mix ? on Result and ? on Option in the same function without converting between them. The compiler will tell you — and Result has an .ok() method to convert to Option, and Option has an .ok_or(err) method to go the other way.
Custom error types with thiserror
For application-level code, Box<dyn Error> is often sufficient as the error type — it accepts anything that implements the Error trait and ? can propagate it through:
fn run() -> Result<(), Box<dyn std::error::Error>> {
let contents = std::fs::read_to_string("data.txt")?;
let count: u32 = contents.trim().parse()?;
println!("count: {count}");
Ok(())
}
That works. But when you’re writing a library, or when callers need to match on specific error variants and respond differently to each one, Box<dyn Error> erases the type. The caller gets an opaque error they can’t pattern-match on.
The answer is a typed error enum. Writing one by hand means implementing std::error::Error, std::fmt::Display, and From conversions manually. The thiserror crate generates all of that from a derive macro:
use thiserror::Error;
use std::io;
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("could not read config file")]
Io(#[from] io::Error),
#[error("invalid field `{field}`: {message}")]
InvalidField { field: String, message: String },
#[error("missing required key `{0}`")]
MissingKey(String),
}
Three things worth noting here:
#[derive(Error, Debug)]generates theErrorandDebugimplementations.#[error("...")]generates theDisplayimplementation — the message callers and users see.#[from]on theIovariant generatesFrom<io::Error> for ConfigError, so?can automatically convert anio::Errorinto aConfigError::Iovariant without any explicit conversion code.
With this error type, callers can match on specific variants: InvalidField might trigger a validation message, MissingKey might fall back to a default, Io might warrant logging and a restart. Box<dyn Error> trades that precision for simplicity. Both have their place — Box<dyn Error> in executables and scripts, typed enums in libraries and any code with distinct failure modes.
When to panic and when to return a Result
| Situation | What to do | Why |
|---|---|---|
| A bug that should never happen (violated invariant) | panic! |
The program is in a broken state — crashing is the right response |
| Input from outside your control (files, network, user input) | Return Result |
External inputs are expected to fail; callers should decide how to handle it |
| Library functions that can fail | Return Result |
Give callers the choice rather than making it for them |
| Code in a test | unwrap() or expect() |
A panic fails the test with a clear message |
| Exploratory code and scripts | unwrap() or expect() |
Acceptable until the code stabilises |
The Rust Book’s rule of thumb: use panic! for conditions that indicate a programming bug, not a runtime state. Use Result for anything where the caller could reasonably make a different decision depending on the failure.
One practical implication: if you’re writing a library, err toward Result. Your callers may want to log the error, retry, fall back to a default, or bubble it further. A panic! takes that choice away from them.
Errors as data change how you write code
The shift that comes from working with Result and Option is subtle. Errors stop being exceptional events that interrupt the normal flow. They become values — things you can pass around, transform, match on, and compose.
A function that can fail has a different type from one that can’t. That’s information, encoded at the type level, available at the call site. When I first started writing Rust after TypeScript, I found that explicitness uncomfortable. There’s more to write. More cases to handle. The compiler keeps finding holes I’d left.
That discomfort fades. What you’re left with is code where failure modes are documented by the type signatures, handled at the point where they happen, and visible to every tool in the chain — the compiler, the editor, the reader.
If you’ve already worked through pattern matching in Rust from Part 6 of this series, you already have the vocabulary for this. Error handling in Rust is pattern matching applied consistently to a pair of well-designed types.