Rust Error Handling, Demystified
A beginner-friendly conversation on Errors, Results, Options, and beyond.
This is Episode 00
TL;DR
-
For beginners.
-
The code is on GitHub.
- Rust has no exceptions:
- Episode 00
- recoverable errors (handled with the
Result<T, E>type). - unrecoverable errors (handled by panicking using
panic!()). - We must explicitly handle errors.
Result<T, E>enum:- Episode 00
- Represents either success (
Ok(T)) or error (Err(E)). - Use
matchexpression or methods like.unwrap()/.expect()(whichpanic!()on error). - Prefer
.expect()with a meaningful message.
?operator for propagation:- Episode 00
- To propagate errors upward with a lite syntax.
- Only works in functions returning a compatible
Result<T, E>(orOption<T>). - When
main()returnsResult<T, E>we can use?here
Option<T>vsResult<T, E>:- Episode 01
- Use
Option<T>when the absence of a value is not an error (e.g., no search result) and no error info is needed. - Use
Result<T, E>when an operation can fail in an exceptional way and we need to convey an error message or reason.
- When to panic:
- Episode 01
- On bugs or invalid states in our code (e.g. asserting invariant).
- If failure is possible in normal operation (e.g. invalid user input…), return a
Result<T, E>. - Library code should avoid panicking on recoverable errors, bubbles them up and let the caller decide.
- Custom error types:
- Episode 01
- For sophisticated libraries or binaries.
- Define our own error types to represent various error kinds in one type.
- Implementing
std::error::Error(=> implfmt::Displayand#[derive(Debug)]) - Use pattern matching or helper methods like
.map_err()(or theFromtrait implementation) to convert std lib errors into our custom error and return it with?
anyhowandthiserror- Episode 01
anyhowin binaries when we don’t need a public, fine-grained error type and just want easy error propagation with.context("blablabla").thiserrorin libraries when we need custom error types without writing all implementations forDisplay,Debug,Fromtrait andError.- Don’t mix them blindly (anyhow inside the lib, thiserror API of the lib)
- From Experimentation to Production:
- Episode 02
- Key Concepts
- 3 experimental prototypes, 1 template
- From Experimentation to Production (including testing) in 7 steps

Let's have a beginner-friendly conversation on Errors, Results, Options, and beyond.
Posts
Table of Contents
- Why Alice and Bob are here?
- Introduction: Why Rust Cares About Errors
- The
Result<T, E>Type: Handling Recoverable Errors - Propagating Errors with
?Operator
Why Alice and Bob are here?
A long time ago (2010, may be) I read a .pdf about the N-Body Simulations. Too bad, I can’t find it on the web anymore, but here’s a copy of volume 1. It was based on Ruby but it was great, easy to follow etc. One thing was that it was written as a conversation between Bob and Alice. Later the code was re-written in Python and the set of .pdf was merged into an expensive printed book (55$ when I bought my copy). Today (sept 2025) you can find it on AMZN for 28€.

Moving Planets Around
Last week I start reading The Basics of Bitcoins and Blockchains (AMZN).

The Basics of Bitcoins and Blockchains
In Part 3, there is a section Why Alice and Bob. Believe it or not, I then discovered where they came from.
I like to write in a conversational tone, so let’s imagine a discussion between Bob and Alice and let’s see how it goes…
Introduction: Why Rust Cares About Errors
Alice: I ran a Rust code snippet and it forced me to handle an error – it wouldn’t even compile until I did! What’s going here?
Bob: The compiler (rustc) makes sure we acknowledge and handle errors properly before our code even runs. This helps prevent crashes at runtime.
Alice: There are no exceptions at all?
Bob: Exactly. Rust doesn’t have exceptions. Instead, it has a different model for errors. Essentially, Rust groups errors into two categories: recoverable and unrecoverable.
- Recoverable errors are things we expect might happen and can be dealt with (like a file not found – we might just create the file or use a default). These are handled with the
Result<T, E>type. - Unrecoverable errors are bugs on our side or unexpected conditions (like indexing past the end of an array – something’s really bad if that happens). For these cases Rust provides the
panic!()macro to stop the program.
Alice: So Result<T, E> is for errors I can handle, and panic!() is for the program-halting ones?
Bob: Yes!
- Think of
Result<T, E>as Rust’s way of saying “operation might succeed or fail”. We then decide what to do if it fails. - Whereas a
panic!()is Rust saying “I can’t deal with this, I must crash now”.
By making error handling explicit with Result, Rust ensures we don’t just ignore errors. It won’t let us compile unless we either handle the Result<T, E> (e.g. [exempli gratia],check for an error) or explicitly choose to crash (like using .unwrap() which triggers a panic!() if there’s an error). This leads to more robust programs because we’re less likely to have an error go unnoticed.
Alice: Um… This is may be a silly question but, if I know my function can succeed or fail, can it returns Result<T, E>.
Bob: Yes, absolutely! Returning a Result<T, E> is not limited to functions in the std library. All your function, even main() can return Result<T, E> and it is a very good practice. Before writing any function code, ask yourselves “can this function fail? Should it return Result<T, E> (or Option<T>)?”. Then work on the rest of the function’s signature.
Alice: It’s a bit scary that the program can just crash with panic!() though.
Bob: Again, panic!() is for cases that are not supposed to happen like an invariant being broken. And even when a panic!() occurs, Rust will unwind the stack and cleanup (or we can opt to abort immediately). Most of the time, you’ll use Result<T, E> for possible errors and only panic!() on bugs that are your responsibility. We’ll talk more about choosing between them later.
Alice: This is may be too early but how can I opt to abort immediately?
Bob: Your’re right, it’s too early but your wishes are my commands. In Cargo.toml add the following section:
[profile.release]
panic = "abort"
The default is unwind. With abort opted in:
- No cleanup: at the first panic, the program terminates immediately with an
abort(). - No destructor (Drop) is invoked.
- This reduces the binary size and the build time (fewer symbols to generate)
Alice: Ok… So Rust wants me to handle every error. This will be a pain… How do I actually do that with Result<T, E>? What does a Result<T, E> look like?
Bob: That’s a good question. We’ll answer it by examining how Result<T, E> works and how to use it, but before that, it’s time to recap and practice a little.
Summary – Introduction –>
Summary – Introduction
- Rust requires we handle errors explicitly. Code that can fail must return a
Result<T, E>(orOption<T>), forcing the caller to address the possibility of failure.- Rust distinguishes
- recoverable errors (e.g. file not found, invalid input – handled with
Result)- unrecoverable errors (bugs like out-of-bounds access – handled with
panic!()).- No exceptions are used. This language design decision helps prevent unchecked errors. We either deal with the error or deliberately choose to
panic!()/.unwrap(), making error handling clear in the code.
Exercises – Introduction
- Identify Error Types: Think of two scenarios in programming:
- one that would be a recoverable error
- one that would be an unrecoverable error
For each scenario, explain whether we would use Rust’s
Result<T, E>or apanic!(), and why. - Compile-time Check: Write a Rust code that attempts to open a non-existent file with
std::fs::File::open(foo.txt)without handling the returnedResult<T, E>. Observe the compiler error or warning. Then, fix it by handling theResult<T, E>(for now, we can just use a simplepanic!()or print an error message in case ofErr). One can read this page
Optional - Setting Up our Development Environment
Let’s make sure we can Debug the code.
Requirements:
- I expect either
CodeLLDBextension (code --install-extension vadimcn.vscode-lldb)- or the
Build Tools for Visual Studioto be installed.
CodeLLDB might be faster and easier to install (after all, it is “just” a VSCode extension). Both can be installed on your PC if you wish or need (this is my case). We will see how to use one or the other. We need them to debug our code.
- I also expect the
command-variableextension to be installed. We need it also to debug our code.code --install-extension rioj7.command-variable
This said, I use VSCode under Windows 11 and I wrote a post about my setup. Here I use a workspace because I can have more than one package in a single “space”. Think of workspaces as meta-package.
Now, having this in mind here is what I do and why.
- Get a copy of the repo from GitHub
- Right click the directory name then select
Open with Code - Once in VSCode, click on
00_u_are_errors/examples/ex00.rsin the VSCode editor
At the time of writing here is what I see:

Click on the image to zoom in
-
Just for testing purpose, delete the
target/directory if it exists (at this point it should’nt exist yet since you just got the workspace from GitHub) -
Press
CTRL+SHIFT+B. This should build a debug version of the code- Check
target/debug/examples/. It should containsex00.exe - If
CTRL+SHIFT+Bdoes not work, open a terminal (CTRL+ù on a French keyboard) and use this command:cargo build -p u_are_errors --example ex00 - You need
-p u_are_errorsbecause in a workspace we need to indicate the name of the package (which you find inCargo.toml)
- Check
If and only if (LLDB || Build Tools for Visual Studio) && command-variable are installed
- Set the cursor on line 5 then press
F9 - This set a breakpoint on line 5. See below:
- Open de
Run & Debugtab on the side (CTRL+SHIFT+D) - In the list box, select the option corresponding to your configuration (LLDB or MSVC). See below:
- Press
F5- This starts the debug session
- If needed the application is built (not the case here because it is already built)
- The execution stops on line 5. See below:
- Press
F10to move forward- Line 5 is executed
- On the left hand side, in the Local subset of variables, we can check that
bobis now equal to 5. See below:
- Press
F5to continue and reach the end of the code
Let’s make a last test. Just to make sure…
- Delete the
target/directory- Select it then press
DELETE
- Select it then press
exe00.rsshould still be open in the editor with a breakpoint set on line 5- Press
F5- The
ex00.exeis built - The debug session starts
- Execution stops on line 5 as before
- The
The making of:
If you read this post for the first time, you can skip this section and come back to it later when you really need to understand how compilation and debugging tasks work.
The secret ingredient lies in ./vscode/task.json and ./vscode/launch.json
1. .vscode/tasks.json:
{
"version": "2.0.0",
"tasks": [
{
"label": "cargo-build-debug",
"type": "cargo",
"command": "build",
"args": [
"-p",
"${input:packageName}",
"--example",
"${fileBasenameNoExtension}"
],
"problemMatcher": ["$rustc"],
"group": { "kind": "build", "isDefault": true }
},
{
"label": "cargo-build-release",
"type": "cargo",
"command": "build",
"args": [
"--release",
"-p",
"${input:packageName}",
"--example",
"${fileBasenameNoExtension}"
],
"problemMatcher": ["$rustc"]
},
],
"inputs": [
{
"id": "packageName",
"type": "command",
"command": "extension.commandvariable.transform",
"args": {
"text": "${relativeFileDirname}",
"find": "^(.{3})([^\\\\/]+)(?:[\\\\/].*)?$",
"replace": "$2"
}
}
]
}
- In the first object of the array
tasks, the key namedgrouphelps to make the task the one by default. This explains how and whyCTRL+SHIFT+Bworked. - Note that since the source code to compile is in the
examples/directory, we pass--exampleand the name of the file (see${fileBasenameNoExtension}, e.g.ex00) in theargsarray. - Since we are in a workspace we need
-pfollowed by the name of the package (input:packageName) - If you get lost, just review the build command you enter in the terminal before. What we do here is exactly the same thing:
cargo build -p u_are_errors --example ex00. Except that we want to discover the name of the package dynamically. Indeed not all the source code are in theu_are_errorspackage. You may have seen the 2 other directories:01_experimentationand02_productionfor example.- In
01_experimentation/, inCargo.toml, the name of the package isexperimentationfor example
- In
- Finding out the name of the package is done in the
inputsarray and this is where thecommand-variableextension shines. Indeed we create a variablepackageNamewhich is initialized with the output of a command which is a regular expression applied to the${relativeFileDirname}of the source code opened in the editor.- To make a long story short from
01_experimentation/examples/it extractsexperimentation
- To make a long story short from
- Then the
${input:packageName}variable can be used in the build tasks.
To see the list of tasks, in VSCode, press ALT+T then press R. Below we can see both tasks:
cargo-build-debug- and
cargo-build-release
2. .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"type": "cppvsdbg",
"request": "launch",
"name": "Debug (MSVC)",
"program": "${workspaceFolder}/target/debug/examples/${fileBasenameNoExtension}.exe",
"args": [],
"cwd": "${workspaceFolder}",
"environment": [
{
"name": "RUST_BACKTRACE",
"value": "short"
}
],
"preLaunchTask": "cargo-build-debug"
},
{
"type": "cppvsdbg",
"request": "launch",
"name": "Release (MSVC)",
"program": "${workspaceFolder}/target/release/examples/${fileBasenameNoExtension}.exe",
"args": [],
"cwd": "${workspaceFolder}",
"environment": [
{
"name": "RUST_BACKTRACE",
"value": "short"
}
],
"preLaunchTask": "cargo-build-release"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug (CodeLLDB)",
"program": "${workspaceFolder}/target/debug/examples/${fileBasenameNoExtension}.exe",
"args": [],
"cwd": "${workspaceFolder}",
"sourceLanguages": ["rust"],
"preLaunchTask": "cargo-build-debug"
}
]
}
- There are 3 objects in the array
configurations. This is why we can debug code with LLDB or MSVC. The third helps to launch the release version. - In each object of the array
configurations, the path in theprogramkey, points to the executable created at the end of the build (do you see${fileBasenameNoExtension}?) - Note the
preLaunchTaskkey. It explains why we can press F5 (debug) even if the executable is not yet built. In such case, the taskcargo-build-debugis executed then the debug session starts.
Solution to Exercice #2
Let’s take some time and see how one could work on the second exercice. If I search “Rust open txt file” on Google, one of the links drive me to the excellent Rust By Example. See below:

This is great because on line 11 it uses std::fs::File::open() but it is a little bit too complex for me and it seems it handles errors while I want the compiler to complain then fix the errors.
Copy/paste/save the file as ex01.rs or open your eyes: the code is already in 00_u_are_errors/examples/ex01.rs. To make sure the code works as expected I can press F5 or open a terminal then enter cargo run -p u_are_errors --example ex01. Here is what I see in the Debug Console once I pressed F5:

The code cannot find hello.txt and panic!().
Copy/paste/save the file as ex02.rs. From the previous code, I just keep what I need:
// ex02.rs
fn main() {
let f = std::fs::File::open("foo.txt");
println!("'f' after std::fs::File::open() = {:?}", f);
}
Surprisingly if I press F5, it builds and runs in a debug session without complain.
------------------------------------------------------------------------------
You may only use the C/C++ Extension for Visual Studio Code with Visual Studio
Code, Visual Studio or Visual Studio for Mac software to help you develop and
test your applications.
------------------------------------------------------------------------------
ex02.exe (30708): Loaded 'C:\Users\phili\OneDrive\Documents\Programmation\rust\01_xp\018_err_for_blog_post\u_are_errors\target\debug\examples\ex02.exe'. Symbols loaded.
ex02.exe (30708): Loaded 'C:\Windows\System32\ntdll.dll'.
ex02.exe (30708): Loaded 'C:\Windows\System32\kernel32.dll'.
ex02.exe (30708): Loaded 'C:\Windows\System32\KernelBase.dll'.
ex02.exe (30708): Loaded 'C:\Windows\System32\ucrtbase.dll'.
ex02.exe (30708): Loaded 'C:\Windows\System32\vcruntime140.dll'.
'f' after std::fs::File::open() = Err(Os { code: 2, kind: NotFound, message: "Le fichier sp├®cifi├® est introuvable." })
ex02.exe (30708): Loaded 'C:\Windows\System32\kernel.appcore.dll'.
ex02.exe (30708): Loaded 'C:\Windows\System32\msvcrt.dll'.
The program '[30708] ex02.exe' has exited with code 0 (0x0).
In fact, with my setup, if I press CTRL+ALT I can reveal the datatype. Click on the screen capture below and look for the data type in gray:

We can see that f is a Result<File, Error>. It is a Result<T, E> but what happens, is that when I asked to print it with {:?}, Rust displays the content of the Result<T, E> and this is why we can see:
'f' after std::fs::File::open() = Err(Os { code: 2, kind: NotFound, message: "Le fichier sp├®cifi├® est introuvable." })
In fact despite ourselves, we cheat. We call a function returning Result<T, E> in a context that expects a file f without using it (e.g. trying to read something).
Copy/paste/save the file as ex03.rs. Let’s make sure the build system complains. Modify the previous code with the one below:
// ex03.rs
fn main() {
let f: std::fs::File = std::fs::File::open("foo.txt");
println!("{:?}", f);
}
On the lhs of the equal sign, I express my expectation. I expect a std::fs::File. Obviously this does not fly very well. We don’t even need to try to build. Indeed, the red squiggles warn us and if we hover them with the cursor we get a clear explanation and some advises. See below:

Copy/paste/save the file as ex04.rs. Let’s find a solution. Modify the previous code with the one below:
// ex04.rs
use std::fs::File;
fn main() {
let result_file = File::open("00_u_are_errors/foo.txt");
match result_file {
Ok(file) => println!("Successfully opened file: {:?}", file),
Err(why) => panic!("Panic! opening the file: {:?}", why),
}
}
result_fileis aResult<T, E>, it is not aFile. It took me a moment to realize that when reading the code.matchis an expression. It is not a statement. This one is easy because almost everything is an expression in Rust.- If the difference between expression and statement is not crystal clear follow and read the 2 previous links.
- The
matchexpression forces us to handle all possible cases - Set a break point on the line with
matchexpression (F9) - Press F5 and step forward with F10
- The code starts from the directory at the root of the workspace. This explains why I need to specify
00_u_are_errors/foo.txtto test theOK()arm (withfoo.txtin../workspace_directory/00_u_are_errors/foo.txt)
Copy/paste/save the file as ex05.rs. Let’s take advantage of the fact that match is an expression. Modify the previous code with the one below:
// ex05.rs
use std::fs::File;
use std::io::Read;
fn main() {
let result_file = File::open("00_u_are_errors/foo.txt");
let mut bob = match result_file {
Ok(alice) => alice,
Err(why) => panic!("Panic! opening the file: {:?}", why),
};
println!("{:?}", bob);
let mut s = String::new();
match bob.read_to_string(&mut s) {
Ok(_) => print!("Content:\n{}", s),
Err(why) => panic!("Panic! reading the file: {:?}", why),
}
}
Side Note
I know,
bobandaliceare weird variable names in this context. I just want to make clear thataliceexists only inside the body ofmatchwhilebobexists outside thematch. Remember from the Rust By Example we had variable shadowing on thefilevariable. We had:let mut file = match File::open(&path) { Err(why) => panic!("couldn't open {}: {}", display, why), Ok(file) => file, };
- The outer
let mut file = …;declares a new variablefilein the current scope.- Inside the
Ok(file)match arm, the namefileis a pattern variable that temporarily binds theFileobject contained in theOk()variant.- That inner
filevariable is shadowing the outer one just for the right-hand side of the assignment.- Once the match expression finishes evaluating, the inner
fileis moved out of theOk(file)pattern and becomes the value assigned to the outerfile.- This is a case of variable shadowing.
- The
filein the match pattern and thefilebound by theletare two distinct variables with the same name, in different scopes.
This said, let’s go back to the source code:
- As before,
std::fs::File::open()returns aResult<File, io::Error>, that we store inresult_file. - Since
matchis an expression, it evaluates to a value, and with the firstmatchwe assign that value tobob. - It is important to understand that
matchdestructures theResult<T, E>. So that the body of thematchcan be read as:- If the
Result<File, io::Error>inresult_filematches the patternOk(alice), then the innerFileis bound to the variablealice, and thatFileis returned from thematch. This meansbobnow owns the file handle. - If it matches
Err(why), the program callspanic!. Thepanic!macro has the special “never” type (!) which never resolve to any value at all. So this arm never returns. This allows the entirematchexpression to still have typeFile. This arm prints a short message then, “Don’t press the little button on the joystick, abort! abort! abort!”
- If the

Don't push the little button on the joystick
Run the code (F5) to see it in panic!()

Now rename the file foo.txt.bak at the root of the directory (00_u_are_errors/) to foo.txt and run the code (F5)

- When the code doesn’t
panic!onopen(), we firstprintln!()theDebugrepresentation ofbob. - Then we call
read_to_string()onbob. The method returns anio::Result<usize>, which is just a type alias forResult<usize, io::Error>. On success, theusizeis the number of bytes read. - In the second
matchwe don’t care about this number, so we use_to ignore it. Instead, we justprintln!()the contents of theString s. - On
Err, the code callspanic!again, prints a message, and the program aborts.
Um… And how do I know io::Result<usize> is a type alias for Result<usize, io::Error>?
Green Slope:
- Set the cursor on
read_to_string - Press F12 (Go To Definition)
- We can see the function signature:
fn read_to_string(&mut self, buf: &mut String) -> io::Result<usize> - Hover
io::Result<usize> - We get access to the type alias
pub type Result<T> = result::Result<T, Error>

North Face:
- Open the std web page
- On the left, find the Crates section
- Click on std
- Look for and click on io
- On the right, go down to the Type Aliases section
- Click on Result
- The page Type Alias Result page explains what is going on:
pub type Result<T> = Result<T, Error>;
K12
- Open the std web page
- On the left, find the Crates section
- Click on std
- Look for and click on io
- On the right side, go down and find the Traits section
- I know I must reach the Traits section and not the Functions section (where there is a
read_to_string) - Because at the top of the source code if I hover the line
use std::io::Read;I’m toldReadis a trait.
- I know I must reach the Traits section and not the Functions section (where there is a

- Click on Read
- At the top you see the function signature:
fn read_to_string(&mut self, buf: &mut String) -> Result<usize> { ... } - At the very end click on
Result<usize> - The page Type Alias Result page explains what is going on:
pub type Result<T> = Result<T, Error>;
I know what you think. But we need to invest time in learning how to navigate and read the documentation. For example instead of asking Google or ChatGPT, I may want to spend time and loose myself in the documentation of std looking for functions to read a .txt file. Or I can look for a sample code in Rust By Example then search for the function signature in the std documentation… Read and navigate the documentation no one can do it for you.
It took us some time to reach that point but from now on I consider we know:
- How to play with code in the package
- How to build (CTRL+SHIFT+B)
- How to set breakpoint (F9) and how to debug (F5)
- How to navigate the documentation (good luck!)
It is time to move on and to dive in Result<T, E>.
The Result<T, E> Type: Handling Recoverable Errors
Alice: So, Result<T, E>… What exactly is it?
Bob: Result<T, E> is an enum (like a tagged union) defined roughly like this:
enum Result<T, E> {
Ok(T), // success, holding a value of type `T`
Err(E), // failure, holding an error value of type `E`
}
It’s a generic enum with two variants:
Ok(T)means the operation succeeded and yielded a value of typeTErr(E)means it failed, yielding an error of typeEdescribing what went wrong
For example, when we try to open a file, the success type T is a file handle ( std::fs::File ), and the error type E is std::io::Error.

Alice: How do I use it? Let’s say I call a function that returns a Result. What do I do with that?
Bob: We have to check which variant it is. Typically, we use a match expression or one of many helper methods. Let’s do a simple example. Suppose we try to parse an integer from a string – this can fail if the string isn’t a number. Copy/paste/try this code in Rust Playground:
fn main() {
let text = "42";
let number_result = text.parse::<i32>(); // parse() returns Result<i32, ParseIntError>
match number_result {
Ok(n) => println!("The number is {n}"), // If parsing succeeds, use the number.
Err(e) => println!("Could not parse number: {e}"), // If it fails, handle the error.
}
}
In this code, text.parse::<i32>() will return an Ok(42) if the string is a valid integer, or an Err(e) if it isn’t (for example, if text = "hello" ). We then match (destructure) on the number_result:
- in the
Okarm, we get the parsedi32numbernand print it - in the
Errarm, we get an errore(of typestd::num::ParseIntErrorin this case) and print an error message.
This way we’ve handled both outcomes explicitly. Using match is the standard way to handle a Result<T, E> because it forces us to consider both success and error cases.
Alice: Cool, but matching on every Result<T, E> is verbose. No?
Bob: True and this is why Rust provides utility methods on Result<T, E> to make life easier. For example, if we just want to crash on error (perhaps in an experimentation), we can use .unwrap() or .expect(...). These will check the Result<T, E> for us:
.unwrap()returns the success value if it’sOk, but if it’s anErr, it willpanic!()right there..expect(msg)does the same but lets us provide a custom panic error message.
Alice: So .unwrap() is basically a shortcut for “give me the value or panic”?
Bob: Exactly. For example copy/paste/try this code in Rust Playground:
fn main() {
let text = "not a number";
// This will panic because the string can't be parsed as i32
let number = text.parse::<i32>().unwrap();
}
If we run this, it will panic with a message like: thread 'main' panicked at src/main.rs:4:38: called 'Result::unwrap()' on an 'Err' value: ParseIntError { kind: InvalidDigit }
Because “not a number” can’t be parsed, parse returns an Err, and .unwrap() triggers a panic!().
By contrast, if text = "42", .unwrap() would succeed and give us the i32 value 42 without any panic.
Alice: Got it. And .expect() is similar but with my own message?
Bob: Right. We might do:
let number = text.parse::<i32>().expect("Expected a number in the string");
If it fails, we would get a panic!() with our message: 'Expected a number in the string: ParseIntError { ... }'. Using .expect() with a clear message is considered better style code compared to .unwrap(), because if a panic happens, the message helps us track down the source and reason.
In fact, developers should prefer .expect() over .unwrap() so that there’s more context in case of a crash.
Alice: So I should avoid .unwrap() and use expect() with a good message if I must panic on an error?
Bob: Yes, that’s a good rule of thumb. Even better, where possible, handle the error gracefully instead of panicking. .unwrap()/.expect() should be used sparingly – basically in scenarios where we are very sure Err won’t happen or in code snippet, sample code for brevity.
One more thing: Result<T, E> has other handy methods:
.unwrap_or_default()will.unwrap()the value or give a default if it’s an error (no panic)..unwrap_or_else(f)where we can run a closure to generate a fallback value or do some other handling for the error.
To show how to use .unwrap_or_default(), here below is a code you can copy/paste in Rust Playground. Note that the default is the default of the current data type (0 for i32, “” for a String…)
fn main() {
// Option<i32>
let some_number: Option<i32> = Some(42);
let none_number: Option<i32> = None;
// unwrap_or_default() gives the value if Some, or the default (0 for i32) if None
println!("Some(42).unwrap_or_default() = {}", some_number.unwrap_or_default());
println!("None::<i32>.unwrap_or_default() = {}", none_number.unwrap_or_default());
// Option<String>
let some_text: Option<String> = Some("Hello".to_string());
let none_text: Option<String> = None;
// Default for String is empty string ""
println!("Some(\"Hello\").unwrap_or_default() = '{}'", some_text.unwrap_or_default());
println!("None::<String>.unwrap_or_default() = '{}'", none_text.unwrap_or_default());
}
The code below shows how to use .unwrap_or_else(f). The tricky part might be the source code layout
// ex06.rs
fn main() {
let some_number: Option<i32> = Some(42);
let none_number: Option<i32> = None;
// unwrap_or_else takes a closure that computes a fallback value
println!(
"Some(42).unwrap_or_else(...) = {}",
some_number.unwrap_or_else(|| {
println!("Closure not called, since we had Some");
0
})
);
println!(
"None::<i32>.unwrap_or_else(...) = {}",
none_number.unwrap_or_else(|| {
println!("Closure called, computing fallback value...");
100
})
);
}
With this code it might be a good idea to open ex06.rs in the editor, set a breakpoint on line 5, press F5, click on the DEBUG CONSOLE tab when the execution is paused and then to press F10 to step over line by line.

Alice: Earlier, we mentioned opening files… Is that similar with Result<T, E> ?
Bob: Yes. Opening a file is a classic example of a function returning Result. Let’s look the code below in Rust Playground:
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let file_path = "hello.txt";
let result = File::open(file_path); // Result<File, std::io::Error>
let file = match result{
Ok(file_handle) => file_handle,
Err(error) => {
if error.kind() == ErrorKind::NotFound {
// If file not found, try to create it
File::create(file_path).expect("Failed to create file")
} else {
// For other errors (e.g., permission denied), panic
panic!("Problem opening the file: {:?}", error);
}
}
};
println!("File opened: {:?}", file);
}
Here, File::open returns a Result<File, Error> – it could be Ok(file_handle) if the file exists and was opened, or Err(error) if something went wrong (file missing, no permission, etc.).
We then match on it:
- If the error kind is
NotFound, we attempt to create the file (which itself could error, so we use.expect()to crash if even creation fails). - For any other kind of error, we just panic immediately.
This way, we handle the “file not found” case by recovering (creating a new file) and let other errors bubble up as a panic!(). This example shows how we might handle different error scenarios differently by inspecting the error (here using error.kind()).
Alice: I see. We could also handle it differently, like notify the user or retry, depending on the context.
Bob: Exactly. The point is that with Result<T, E>, we decide how to handle it. We could propagate it up, log it, ignore it (not recommended without justification), or crash. But we have to choose. That’s the strength of the design: we won’t accidentally ignore an error.
Summary – The Result<T, E> Type Basics
Summary – The
Result<T, E>Type Basics
Result<T, E>is an enum: with variantsOk(T)(success) andErr(E)(error).- Handle with
matchor methods:
match
- Using
matchon aResult<T, E>forces explicit handling of success and error.matchdestructures theResult<T, E>- Inside an
Ok(file)match arm, the namefileis a pattern variable that temporarily binds theFileobject contained in theOk()variant of the enumResult<T, E>.- Methods
- Use
.unwrap()/.expect()to get the value orpanic!()on error.- Use
.unwrap_or_default()/.unwrap_or_else(func)to provide fallbacks instead of panicking.- Prefer
.expect(): If we choose topanic!()on an error, prefer.expect("custom message")over plain.unwrap(). It gives a clearer error message for debugging when the unexpected happens.
Exercises – Result<T, E> Basics
-
Can you find
Result<T, E>in std documentation? -
Match Practice: Write a function
parse_number(text: &str) -> i32that tries to convert a string to an integer. Usematchontext.parse::<i32>()(which gives aResult<i32,std::num::ParseIntError>) and return the number if successful, or print an error and return0if not. Test it with both a numeric string and a non-numeric string. -
.unwrap() vs .expect(): Using the same
parse_numberlogic, create another functionparse_number_expect(text: &str) -> i32that does the parsing but uses.expect()instead ofmatchto crash on error (with a custom message likeFailed to parse number). Call this function with a bad input to see the panic message. Then replace.expect()with.unwrap()to see the default panic message. Note the difference in the panic outputs. -
File Open Challenge: Write a small program that attempts to open a file (e.g.,
config.txt). If it fails because the file is missing, have the program create the file and write a default configuration to it (we can just write a simple string). If it fails for any other reason, have it print a graceful error message (instead of panicking). Use pattern matching on theErr(e)ande.kind()as shown above to distinguish the cases.
Propagating Errors with ? Operator
Alice: This match stuff is okay, but if I have to bubble up errors from multiple functions, writing a match expression in each function sounds painful.
Bob: You’re in luck – Rust has a convenience for that: the ? operator. It’s a little piece of syntax that makes propagating errors much nicer.
Alice: I think I already saw ? here and there in some Rust code. How does it work?
Bob: The ? operator is essentially a shortcut for the kind of match-and-return-on-Err logic we’ve been writing. When we append ? to a Result<T, E> (or an Option<T>), it will check the result:
- If it’s Ok , it unwraps the value inside and lets our code continue
- If it’s an Err, it returns that error from the current function immediately, bubbling it up to the caller. This means we don’t have to write the
matchourself,?does it for us.
Alice: So it returns early on error? Nice, that’s like exceptions but checked at compile time.
Bob: Right, it’s analogous to exception propagation but explicitly done via return values. Let’s refactor a source code that use match expressions into one using ? operator. First copy/paste and execute (CTRL+ENTER) the code below in Rust Playground. It works but… Too much match everywhere…
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut file = match File::open("username.txt") {
Ok(file) => file, // success, variable shadowing on file, continue
Err(e) => return Err(e), // early return
};
let mut username = String::new();
match file.read_to_string(&mut username) {
Ok(_) => Ok(username), // success, returns
Err(e) => Err(e), // returns the error e
} // no ; here
}
fn main() {
match read_username_from_file() {
Ok(name) => println!("Username: {name}"),
Err(e) => eprintln!("Error reading username: {e}"),
}
}
Now, modify the code above in Rust Playground and when it is working paste it, locally in ex07.rs.
// ex07.rs
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut file = File::open("username.txt")?; // if Err, returns Err up
let mut username = String::new();
file.read_to_string(&mut username)?; // if Err, returns Err up
Ok(username) // if we got here, all good
}
fn main() {
// Use the function and handle any error here
match read_username_from_file() {
Ok(name) => println!("Username: {name}"),
Err(e) => eprintln!("Error reading username: {e}"),
}
}
While ex07.rs is open in VSCode:
- Set breakpoints on lines 7 and 15
- Run the code (F5)
- When the application is done, there is a file named
username.txt.bakat the root of the directory (00_u_are_errors/), rename itusername.txt. - Restart the code (F5)
- When the application is done, open and delete the content of
username.txt - Run the code (F5)

Bob: First thing first. Do you see the return type in the signature of read_username_from_file(). This confirms, and this is a very good thing, that we can return Result<T, E> from our functions:
- At the end of the function, if everything went well we return
OK(username) - Otherwise we bubble up the errors with the help of the
?operator. Do you see those?afterFile::openandread_to_string? If either operation fails, the function returns aErr(io::Error)back to the caller.
This pattern is so common that using ? is idiomatic. It makes the code much cleaner by avoiding all the boilerplate of matching and returning errors manually.
Alice: That’s much shorter! And in main() we decided to handle the error with a match . Could I propagate the error from main() as well?
Bob: This is a very good point. In fact, yes we can! In “modern” Rust, the main() function itself can return a Result<T, E> (or any type that implements the Termination trait, like Result<T, E> does).
This is a feature that let us use ? even in main() . For example:
// ex08.rs
use std::error::Error;
use std::fs::File;
fn main() -> Result<(), Box<dyn Error>> {
let file = File::open("username.txt")?; // if this errors, `main()` will return Err
println!("File opened successfully: {:?}", file);
Ok(())
}

By writing fn main() -> Result<(), Box<dyn Error>>, we indicate that main() might return an error. The Box<dyn Error> is a convenient way to say that the returned error could be of any type that implements the std::error::Error trait.
Now, using ? in main() is allowed because the error can be returned from main(). If an error occurs, the runtime will print the error and exit with a non-zero status code. If main() returns Ok(()) , the program exits normally with code 0.
This is really nice for quick scripts – we can just propagate errors out of main() and let the program crash gracefully with an error message, rather than writing a lot of error handling in main().
We can go one step further with the code below:
// ex09.rs
use std::fs::File;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; // Type Alias
fn main() -> Result<()> {
let file = File::open("username.txt")?;
println!("File opened successfully: {:?}", file);
Ok(())
}
It does exactly the same thing but thanks to type aliases, we lighten the signature of main(). Note that the line use std::error::Error; is no longer necessary.
Alice: So ? can be used in any function that returns a Result<T, E> or Option<T> right?
Bob: Correct. The rule is: we can use ? in a function if the return type of that function can absorb the error. Typically, that means if our function returns a Result<T, E>. We can use ? on another Result<T, E2> as long as E2 can convert into E. Usually they’re the same E or there’s an implementation of the From trait to convert one error into the other. Rust does this conversion automatically in many cases.
For example, below, the main() returns a Result<T, Box<dyn Error>>, but calls parse::<i32>(), which returns a ParseIntError. Rust performs the conversion automatically using From<ParseIntError> for Box<dyn Error>.
// ex10.rs
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn parse_number(s: &str) -> Result<i32> {
// `parse::<i32>()` returns Result<i32, ParseIntError>
// The `?` operator works here because ParseIntError implements
// the `Error` trait, and Rust knows how to convert it into Box<dyn Error>.
let n: i32 = s.parse()?;
Ok(n)
}
fn main() -> Result<()> {
let value = parse_number("123sdfsdf")?;
println!("Parsed value: {value}");
Ok(())
}
If our function returns Option<T> , we can use ? on another Option<T>. If it’s None, our function returns None early. Play with the code below:
// ex11.rs
fn first_char_upper(s: &str) -> Option<char> {
// `first_char_upper()` returns Option<char>
// `chars().next()` returns Option<char>
// => we can use `?` at the end of s.chars().next()
// If it's None, the function returns None early
let c = s.chars().next()?;
Some(c.to_ascii_uppercase())
}
fn main() {
println!("{:?}", first_char_upper("hello")); // Some('H')
println!("{:?}", first_char_upper("")); // None
}
Please note that the code below would work as well.
fn first_char_upper(s: &str) -> Option<f64> {
let c = s.chars().next()?; // c: char
Some(42.0)
}
It compiles without any problems because the ? always outputs a char but the compiler doesn’t care that our function returns an Option<f64>. It just checks that the ? “absorbs” the Option<char> by returning None when necessary. Then it’s up to us to transform the char into whatever we want (in this case, an f64).
One thing to remember: we can’t mix return types with ?. For example, if our function returns a Result, we can’t directly use ? on an Option<T> without converting it (and vice versa). For example the code below does not compile:
// ex12.rs
// ! DOES NOT COMPILE
use std::fs::File;
fn bad_example() -> Option<File> {
// `File::open` returns Result<File, io::Error>
// But our function returns Option<File>.
// The compiler rejects this because it cannot convert Result into Option automatically.
let file = File::open("username.txt")?;
Some(file)
}
fn main() {
let f = bad_example();
println!("{:?}", f);
}
See part of the message from the compiler on build:
error[E0277]: the `?` operator can only be used on `Option`s, not `Result`s, in a function that returns `Option`
|
8 | fn bad_example() -> Option<File> {
| -------------------------------- this function returns an `Option`
...
12 | let file = File::open("username.txt")?;
| ^ use `.ok()?` if you want to discard the `Result<Infallible, std::io::Error>` error information
There are helper methods like .ok_or() to turn an Option<T> into a Result<T, E> if needed. See below:
// ex13.rs
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn get_first_char(s: &str) -> Result<char> {
// Convert Option<char> into Result<char, String>
s.chars().next().ok_or("String was empty".into())
}
fn main() -> Result<()> {
let c1 = get_first_char("hello")?;
println!("First char: {c1}");
let c2 = get_first_char("")?; // This will return Err
println!("First char: {c2}");
Ok(())
}
Alice: Understood. I really like how ? reduces the clutter. It reads almost like normal linear code, but errors just get propagated automatically.
Bob: Exactly. It’s one of the features that make Rust’s error handling ergonomic. Just be sure that when we use ?, we know what error type our function is returning and that it’s appropriate to let it bubble up to the caller.
Summary – Propagating Errors with ?
Summary – Propagating Errors with
?
?operator: A shorthand for propagating errors. It unwraps theOk()value or returns the error to the caller if it’s anErr(), effectively doing thematch+return Err(...)for us. This simplifies error handling in functions that just want to pass errors up the chain.- Usage requirements: We can only use
?in a function that returns a compatible type (e.g., if the function returnsResult<T, E>orOption<T>). Using?on aResult<T, E>in a function returningResult<T, E>will propagate the error; using it inmain()requiresmain()to return aResult<T, E>as well. If we try to use?in a function that returns()(unit type) or another type that can’t represent an error, the code won’t compile – the compiler will remind we to change the return type or handle the error another way.- Converting error types: When using
?, if the error type of theResult<T, E>you’re handling doesn’t exactlymatchour function’s error type, it will attempt to convert it via theFromtrait. This allows different error types to be mapped into one error type for our function (for example, converting astd::io::Errorinto our custom error type). If no conversion is possible, you’ll get a type mismatch compile error, which we can resolve by using methods like.map_err()or implementingFromfor our error.main()can returnResult<T, E>: To use?at the top level, we can havemain()returnResult<(), E>. This way, anyErrthat propagates tomain()will cause the program to exit with a non-zero status and print the error. For example,main() -> Result<(), Box<dyn std::error::Error>>is a common choice to allow using?inmain()- Let’s keep this snippet in mind
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; fn main() -> Result<()> { // ... Ok(()) }
Exercises – Propagating Errors
- Refactor with
?:- Take one of our functions from the previous exercises (for instance, a file-reading function or the number-parsing function) that handled errors with
match. - Change it to return a
Result<T, E>instead of, say, defaulting to a value, and use the?operator to propagate errors to the caller. For example, change aparse_numberthat returned 0 on error to instead returnResult<i32, std::num::ParseIntError>and use?inside. - Then handle the error at the top level (maybe in
main()) by printing an error.
- Take one of our functions from the previous exercises (for instance, a file-reading function or the number-parsing function) that handled errors with
- Chain calls with
?:- Write two short functions:
fn get_file_contents(path: &str) -> Result<String, std::io::Error>that opens and reads a file (using?), andfn count_lines(path: &str) -> Result<usize, std::io::Error>that callsget_file_contents(using?) and then returns the number of lines in the file. - In
main(), callcount_lines(somefile.txt)and handle the error with amatchor by returning aResult<T, E>frommain()using?. - This will give us practice in propagating errors through multiple levels.
- Write two short functions:
- Using ? with Option:
- Write a function
last_char_of_first_line(text: &str) -> Option<char>that returns the last character of the first line of a string, orNoneif the string is empty or has no lines. - Hint: We can use
text.lines().next()?to get the first line, and thenchars().last()on that line. - The
?will return early withNoneif there is no first line - Test it with an empty string, a single-line string, and a multi-line string.
- Write a function