Rust Error Handling, Demystified
A beginner-friendly conversation on Errors, Results, Options, and beyond.
This is Episode 02

Let's have a beginner-friendly conversation on Errors, Results, Options, and beyond.
Posts
Table of Contents
- Errors from Experimentation to Production
- Key Concepts
- Experimentation
- Path to Production - Step_00
- Path to Production - Step_01
- Path to Production - Step_02
- Path to Production - Step_03
- Path to Production - Step_04
- Path to Production - Step_05
- Path to Production - Step_06
- Summary – Experimentation to Production
- Exercises – Experimentation to Production
- Conclusion
- Webliography
Errors from Experimentation to Production
Alice: It was a lot… Again, many, many thanks because it really helps to organized my thoughts about errors management.
There is, may be, one last thing I would like to discuss with you. I know, I’m still a young Padawan, and most of my projects are just experiments I tinker with on weekends. Ok, ok, ok… But I’m wondering how errors are managed in more “serious” code. I mean, I would like to learn more so that I will not be “lost” while reading code from others on GitHub. More importantly, I would like to put in place the good practices, up front, so that I can transition happily to production.

Bob: Help you in this quest, I can. And since you already know almost everything you need to know, I propose we follow this path:
- First, we’ll recap what we’d like to see — and actually live with — when it comes to error management. Kind of like a wish list, if you will. I don’t have much to add here, since you already have the answers.
- Then we will put ourself in a situation were you start few experimental projects. It will be a good opportunity to write some code, check our knowledge and put in place good practices.
- Finally you will transition your projects in production ready state. At least we will put in place what we need from the error management point of view.
Do you agree?
Alice: This would be perfect. Let’s go.
Key Concepts
Bob: Have you ever heard about the Gall’s law? No? It translates in words your intuition. Indeed you feel the Force but you also feel that, ideally, your sample code will evolve. The law says (read it with a strong voice like in the film The Ten Commandments): “A complex system that works is invariably found to have evolved from a simple system that worked…”

So, good news, you are right. You must start with an experimental code that works and which will evolve (may be) in a million dollar class of application.
I can also confirm you are right when you say that you want to put it in place, up front, an error management system that scales with your app.
Now, I have a question for you. Without entering in the technical details, what do you want from the error management standpoint?
Alice: Um… I would say…
- The sooner the better. I mean, get help from the rust type and build systems to detect most of errors at compile time. You know what I mean.
- The fewer the better. This is obvious. Ideally I don’t want error in my code.
- I told you, I really like the
?operator. It makes the code easy to read. It is my friend. I would like to keep it in the transition from prototype to production. - I want to be able to prototype experimentation code quickly, while still applying the lessons we learned with the custom error type in production.
enumand related features are powerful, but I’m not sure I want to bother with them in my experimental code. - I also remember what we said. If I write a library it should return the errors to the consumer and let him decide. It should almost never
panic!(). - Library should expose one error data type in their API even if internally it use
anyhowand different options. I’m not sure I’m very clear on this point… - What else? An espresso? More seriously, I don’t have much to add, except that I’d like to avoid rewriting my code when transitioning to production.

Bob: It’s really good. You are definitively on the right track. Let’s keep all this in mind and let’s move to the experimentation phase.
Experimentation
Side Note
In the workspace, the source code discussed below are in the
01_experimentation/examples/directory.
Bob: It is Saturday night. The house is silent, your young sister is out (you don’t want to kow where nor with who). This is the best time to play with Rust. No?

Based on what you just learnt, can you write your version of “Hello, World!”?
Alice: “Hello, World!”… It is a very simple code. I would start with:
fn main() {
println!("Hello, world!");
}
Then… Yes, I know what you want. Let’s make sure I can use my friend ? in main(). Since I don’t know yet what kind of std lib and crate functions I will call, I make sure main() can handle and returns all of them. I don’t really remember, but it was based on Box, dyn, blablabla…
Bob: It is not a problem. Go back and review 00_u_are_errors\examples\ex08.rs in Episode 00 for example.
Alice: Thanks for the nudge. So I would write the code like this:
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
println!("Hello, world!");
Ok(()) // we must return a Result whose value here is Ok(())
}
But then I can imagine that other functions in main.rs will need to return the same Result. So in order to simplify the writing of the functions signature I write:
// ex000.rs
ype Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn main() -> Result<()> {
println!("Hello, world!");
Ok(())
}
Bob: Pretty cool. Now, I want you to trust in me, just in me….

Let me rewrite your code like this:
// ex001.rs
pub type Error = Box<dyn std::error::Error>;
pub type Result<T> = std::result::Result<T, Error>;
fn main() -> Result<()> {
println!("Hello, world!");
Ok(())
}
No big change.
- Since we want to use the same code from experimentation to production it is smarter to keep
ErrorandResult<T>type aliases on 2 type alias declarations. Doing so, even if in production, theErrortype evolve to something different (e.g. a custom error type) theResulttype will not be impacted (it will always refers toError) and this is exactly what we want. - Do you see the
pubword? Here it does not really matter because the code is monolithic. Tomorrow, if you want to make sureResult<T>(andError) can be used elsewhere it is better to anticipate and to give them a public access modifier upfront.
By the way do you have any idea of what I did?
Alice: No. You split my line in two and you explained that later if the Error type becomes very complicated, this will have no impact on Result<T>
Bob: I just add what we call a level of indirection which, according to David Wheeler, is THE way to solve most of problems in computer science.
So, at this point, we agree to say that ex001.rs is by now your official code template. Ok? Ok, let’s move on.
Do you know what BMI is?
Alice: Yes I do. My young sister is always talking about it. I read this a statistical value which is more valuable for population than for individuals. It indicates if the group is overweight or not. Basically you take a weight (in kg) and divide it by the square of the height (in meters). This give a result in number of kilograms per square meter. If the group is between 18.5 and 24.9 it is OK.
Bob: Using your code template write a prototype to calculate the BMI.
Alice: Here is what I have so far.
// ex100.rs
pub type Error = Box<dyn std::error::Error>;
pub type Result<T> = std::result::Result<T, Error>;
fn main() -> Result<()> {
let my_bmi = bmi(70.0, 1.7)?;
println!("BMI: {my_bmi:.2}");
Ok(())
}
fn bmi(w: f64, h: f64) -> Result<f64> {
if h.abs() < f64::EPSILON {
return Err("Height cannot be 0.0".into());
}
Ok(w / (h * h))
}
While writing the code, the most difficult part was the line
return Err("Height cannot be 0.0".into());
I lost some time because initially I wanted to write
return Err("Height cannot be 0.0");
But this does’nt work. Indeed bmi() returns a Result<f64>, this means a Result<f64, Box<dyn Error>>. So I have to convert the &'static str into a Box<dyn std::error::Error> first. I hope that now on, I will remember the .into().
Bob: Don’t worry this will come with practice. Now, for a new experiment, I want you to write a function that receives a vector of integers written as strings and returns their sum as an i32.
Alice: If we look at it from the perspective of the main() function, is the code below what you have in mind?
// ex200.rs
pub type Error = Box<dyn std::error::Error>;
pub type Result<T> = std::result::Result<T, Error>;
fn main() -> Result<()> {
let numbers = vec!["10", "20", "89", "30"];
let total = sum_strings(&numbers)?;
println!("The total is: {total}");
Ok(())
}
Bob: Yes, keep going.
Alice: My first idea for sum_strings() is the code below
fn sum_strings(values: &[&str]) -> Result<i32> {
let mut sum = 0;
for s in values {
let current_val = s.parse::<i32>();
sum += current_val.unwrap();
}
Ok(sum)
}
- It returns a
Result<32>so that I can use?inmain() values: &[&str]may look weird but no, it is not. Inmain()I pass the vectornumbersby reference because I borrow it (I don’t want to give it) tosum_strings(). Now inmain(), if I pressCTRL+ALT, I see the exact type ofnumbers(Vec<&'static str>). Sosum_strings()’s parameter is a reference to an array (&[...]) of static strings (&str).- Then, there is a
forloop which traverses the vectorvalues - I remembered we used
.parse()at the beginning of the section “TheResult<T, E>Type: Handling Recoverable Errors” - Pressing
CTRL+ALT, I see.parse::<i32>()returns aResult<i32, ParseIntError> - If
current_valis Ok I add its value to the runningsum, otherwise… With the help of.unwrap()the codepanic!() - At the end of the loop,
sumis a valid number and I return it withOk(sum)
The code work, but to tell the truth, I’m not really proud of the .unwrap() and I know I should avoid the raw loop.
Bob: Then?
Alice: Now, I have this version of sum_strings() without any raw loop
fn sum_strings(values: &[&str]) -> Result<i32> {
let sum: i32 = values
.iter()
.map(|s| s.parse::<i32>().unwrap())
.sum();
Ok(sum)
}
But I remember what we said about .unwrap(), and .expect(). Finally I have this version which prints a custom message on error. See below:
// ex200.rs
pub type Error = Box<dyn std::error::Error>;
pub type Result<T> = std::result::Result<T, Error>;
fn main() -> Result<()> {
let numbers = vec!["10", "20", "oops", "30"];
let total = sum_strings(&numbers)?;
println!("The total is: {total}");
Ok(())
}
fn sum_strings(values: &[&str]) -> Result<i32> {
let sum: i32 = values
.iter()
.map(|s| s.parse::<i32>().expect(&format!("Failed to parse '{}' as integer", s)))
.sum();
Ok(sum)
}
Here is what I can see in the terminal when “oops” is in the initial vector.
thread 'main' panicked at 01_experimentation\examples\ex200.rs:19:59:
Failed to parse 'oops' as integer: ParseIntError { kind: InvalidDigit }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
error: process didn't exit successfully: `target\debug\examples\ex200.exe` (exit code: 101)
Bob: This is pretty cool for a young Padawan. Last but not least I would like you to use your template and write an application that print the names of the files in a directory. Easy? No?
Alice: Same test. Just to make sure… From the point of view of main() is it what you expect?
// ex300.rs
pub type Error = Box<dyn std::error::Error>;
pub type Result<T> = std::result::Result<T, Error>;
fn main() -> Result<()> {
let files = list_files(".")?;
println!("{files:#?}");
Ok(())
}
Bob: Yes. Now, show me the list_files() function please.
Alice: Here is what I have so far (no raw loop):
fn list_files(path: &str) -> Result<Vec<String>> {
let files: Vec<String> = std::fs::read_dir(path)?
.filter_map(|re| re.ok())
.filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.collect();
Ok(files)
}
- I looked around in the documentation and on the web how to list files in a directory with Rust.
- Then I met
read_dir()which returns anio::Result<ReadDir> - When OK it can be used as an iterator over the entries within the directory (there is an
impl Iterator for ReadDir) - If it is an iterator I can daisy chain multiple filters and keep the files of interest
.filter_map(),.filter()and.collect()operate on anIterator<Item = DirEntry>once theResulthas been unwrapped by?right afterread_dir()- These iterator methods do not return a
Result. They cannot fail in a way that would require error propagation. - They simply transform the data from one form to another
- This is why there is no
?at the end of the steps- the first
.filter_map()silently drops entries that errored - the second
.filter()ask the filesystem whether the entry is a file. If that check errors because it is a directory, it is treated as false and not kept in the list of files. - the last
filter_map()only keeps filenames that are valid UTF-8 while the others are dropped
- the first
- The last step is
.collect()which creates a vector with the filtered filenames - Finally the function returns the vector to
main()withOk(files)
Bob: Did you notice how your template worked fine in 3 different experiments? I guess we can keep it in our toolbox.
Now in the last sample code, rather than panicking on error after the call to read_dir(), could you avoid the ? and return a custom message to main() explaining what’s happen?
Alice: Ok… I start by removing the ? then… I don’t know!
Bob: Do you remember the section “Option<T> vs. Result<T, E>: Choosing the Right Type” in Episode 01? We were discussing about the Option<T> and the fact we were loosing the reason why the failure happened. I told you we can return an Option<T> but log the reason of failure. To do so I used .map_err(). Do you remember? Review ex16.rs then come back here.
Alice: I get it. Here is my new version of the code
// ex301.rs
pub type Error = Box<dyn std::error::Error>;
pub type Result<T> = std::result::Result<T, Error>;
fn main() -> Result<()> {
let files = list_files("")?;
println!("{files:#?}");
Ok(())
}
fn list_files(path: &str) -> Result<Vec<String>> {
let files: Vec<String> = std::fs::read_dir(path) // no `?` here
.map_err(|_| "❗Error while reading dir.")? // but `?` is here. On error, return a static string
.filter_map(|re| re.ok())
.filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.collect();
Ok(files)
}
- You are right. The key is to remember
.map_err()and how it works. Let me rephrase my understanding… At the exit ofread_dir()- If the
Resultis anOk(value),.map_err()does nothing. The?operator evaluates tovalueand the execution continues - If the
ResultisErr(e),.map_err()applies the closure toeand returnsErr(closure(e))- Here the closure ignores the actual
io::Error(|_|discards it) and replaces it with a static string slice"Error while reading dir." - The
?operator immediately returns that error from the current function.
- Here the closure ignores the actual
- If the
Now, let me repeat the details of the operations. Just to make sure…
- The return type of the
list_files()function isResult<Vec<String>, Box<dyn std::error::Error>> - So when the
Err(&str)need to be bubbled up, Rust needs to find a way to transform the&strinto aBox<dyn std::error::Error> - The promotion from
&strtoBox<dyn std::error::Error>is possible because std lib includesimpl<'a> From<&str> for Box<dyn Error + 'a>. I took the time to read this page. - This explains why we can return a bare “
Error while reading dir.” and how it gets “promoted” into a properBox<dyn Error>.
This is key
The promotion from
&strtoBox<dyn std::error::Error>works because std lib includes an implementation of theFromtrait which does exactly that. Seeimpl<'a> From<&str> for Box<dyn Error + 'a>.
Bob: I’m truly impressed. Now, even if it is a little bit overkill because we are supposed to be in an experiment, if I ask you to return also the reason why the error occurs I guess it is a matter of seconds. No?
Alice: You’re right. Now it is much easier. Here is the new version of the code
// ex302.rs
pub type Error = Box<dyn std::error::Error>;
pub type Result<T> = std::result::Result<T, Error>;
fn main() -> Result<()> {
let files = list_files("")?;
println!("{files:#?}");
Ok(())
}
fn list_files(path: &str) -> Result<Vec<String>> {
let files: Vec<String> = std::fs::read_dir(path)
.map_err(|why| format!("❗Error while reading dir. Reason = {why}"))?
.filter_map(|re| re.ok())
.filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.collect();
Ok(files)
}
- Above,
.map_err()now returns a custom error as formattedString. - The promotion from
StringtoBox<dyn std::error::Error>works because std lib includesimpl<'a> From<String> for Box<dyn Error + 'a>.
Bob: A Padawan no more, you are. Prove a Jedi Knight you have become… Let’s go back to the first experiment and show me how you would return an meaningful error message if the directory is empty.
Alice: Here is my code
// ex303.rs
pub type Error = Box<dyn std::error::Error>;
pub type Result<T> = std::result::Result<T, Error>;
fn main() -> Result<()> {
let files = list_files("./01_experimentation/empty")?;
println!("{files:#?}");
Ok(())
}
fn list_files(path: &str) -> Result<Vec<String>> {
let files: Vec<String> = std::fs::read_dir(path)?
.filter_map(|re| re.ok())
.filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.collect();
if files.is_empty() {
return Err("Cannot list empty folder.".into());
}
Ok(files)
}
- This time it’s easier because I remember about
.into() - I keep the initial code but once the
filesvector is collected, I check if it is empty. - If it is I return an ad hoc message.
- Otherwise, as before, we reach the end of the body of
list_files(), thefilesvector is Ok and I returnOk(files)
Bob: We are still in the experimentation phase where we can take the time to learn, discover, crash and repair things. Can you tell me, in detail, why and how the .into() works? Take your time, read the documentation before to anser.
Alice: It turned out to be a real caving expedition, and it took me more time than I had anticipated. Sorry about that.

I focus on the lines below:
if files.is_empty() {
return Err("Cannot list empty folder.".into());
}
The .into() works because std lib includes impl<'a> From<&str> for Box<dyn Error + 'a> and here is why:
- When I write
"Cannot list empty folder.".into(); - It starts as a
&'static str - The compiler knows that the expected type is
Box<dyn Error> - It founds
impl<'a> From<&str> for Box<dyn Error + 'a>in the std lib - But in Rust if we have
From<A> to Bthen we getInto<B> for Afor free - Here this means
Into<Box<dyn Error> for &strexists - Then the
static &stris automatically converted toBox<dyn Error>
The story has a happy ending: they got married and lived happily ever after.
This is key
In Rust if the trait
From<A> for Bexists, then we get the traitInto<B> for Afor free.

Summary – Experimentation
Summary – Experimentation
main()return any kind of error that implements theErrortrait?can be used inmain()- In our functions we return custom messages (
.into(),.map_err()…)- Let’s keep this code fragment in mind:
pub type Error = Box<dyn std::error::Error>; pub type Result<T> = std::result::Result<T, Error>; fn main() -> Result<()> { let files = list_files("")?; println!("{files:#?}"); Ok(()) } fn list_files(path: &str) -> Result<Vec<String>> { let files: Vec<String> = std::fs::read_dir(path) .map_err(|why| format!("❗Error while reading dir. Reason = {why}"))? // REST OF THE CODE ; if files.is_empty() { return Err("Cannot list empty folder.".into()); } Ok(files) }
Bob: It’s showtime! Let’s transition to production.
Path to Production - Step_00
Bob: You know what? We will use the last experiment code as a starting point. Again the objective is to transition to a production ready code (at least from the error management standpoint). As it is, the code is monolithic and it looks like this:
// ex303.rs
pub type Error = Box<dyn std::error::Error>;
pub type Result<T> = std::result::Result<T, Error>;
fn main() -> Result<()> {
let files = list_files("./01_experimentation/empty")?;
println!("{files:#?}");
Ok(())
}
fn list_files(path: &str) -> Result<Vec<String>> {
let files: Vec<String> = std::fs::read_dir(path)?
.filter_map(|re| re.ok())
.filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.collect();
if files.is_empty() {
return Err("Cannot list empty folder.".into());
}
Ok(files)
}
The output is:
Error: "Cannot list empty folder."
error: process didn't exit successfully: `target\debug\examples\ex303.exe` (exit code: 1)
What would you do?
Alice: As explained in THE book, I would create a library so that main() acts as a consumer of the exposed API. This will also helps, later, when we will need to write tests… So first thing first, we should split the code according to the responsibilities.
Bob: Ok, but I would like to be very conservative here and go one step at a time. As a very first step I want you to split the code among modules (not lib) and make sure everything works again. You could create a package in a 00_project directory and since you read the Modules Cheat Sheet, use the modern way of doing meaning you’re not allowed to create any mod.rs file. And please, explain what you do as you move forward.
Side Note
From now on, in the workspace, the projects discussed below are in the
02_production/directory.
Alice: OK…
- I create a package in the
02_production/00_project/directory - Below you can see how files and directories are organized
.
│ Cargo.lock
│ Cargo.toml
│
├───empty
└───src
│ main.rs
│ files.rs
│
└───files
listing.rs
- In the
Cargo.tomlthe package is namedstep_00because I suppose we will have more than one step on our path to the Valhalla (production code). Here isCargo.toml:
# Cargo.toml
[package]
name = "step_00"
version = "0.1.0"
edition = "2024"
[dependencies]
- I create a directory named
emptyto perform some tests - Since I can’t yet create a library, there is no
lib.rsin the directory, just amain.rs - Since there is a
main.rsthis means that the crate (the output of the build system) will be a binary crate, an application (step_00.exe) - In
main.rsI basically keep the minimum, amain()function with a call tolist_files(). See below:
// main.rs
pub type Result<T> = std::result::Result<T, Error>;
pub type Error = Box<dyn std::error::Error>;
mod files;
use crate::files::listing;
fn main() -> Result<()> {
let files = listing::list_files("./02_production/00_project/empty")?; // see the ? here
println!("{files:#?}");
Ok(())
}
- The type alias declarations for
ResultandErrorremain unchanged. - The line
mod files;declares the existence and loads a module namedfilesin the binary crate. It includes the content of the module found in the external filefiles.rs. A module is a namespace. The line brings its content to the local scope (crate root). - It is important to understand that the module tree that we start building with the
mod files;declaration is the only thing that matters for the build system. At the top of the tree is the crate root (binary crate here). Then, underneath there is a tree where on each branch and each leaf we have modules (not files). Modules are namespaces which organize code inside the crate. Files do not matter and this is why we can have multiple modules in one file (check what we did inex19.rswith themath_utilsmodule). Files are just containers of modules. Here, the module tree will look like this:
crate The crate root module is stored in main.rs
└─ files The `files` module is stored in files.rs
└─ listing The `listing` module is stored in files/listing.rs
use crate::files::listing;is a shortcut, nothing more.- Rather than writing
files::listing::list_files(), I can writelisting::list_files(). - Alternatively I could write
use crate::files::listing::list_files;and calllist_files()directly but I prefer to writelisting::list_files(). Indeed, 6 months from now, the code will be auto documented and easier to read. I will not have to remember in which modulelist_files()is defined, instead I will “read” thatlist_filesis defined in the module namedlisting.
- Rather than writing
Side Note
If you don’t feel 100% confident with crates, modules, files… You should read this short dedicated post
- In the directory tree,
files.rsis a hub file. I mean it is a short file that declares and loads one or more modules at a given level. Here it declares and loads the modulelistingone level below in the module tree. In other words, since depub mod listing;take place in thefilesmodule then thelistingmodule is a child of thefilesmodule. Review the module tree above (not the directory tree), confirm it makes sense.
// files.rs
pub mod listing;
- And now, ladies and gentlemen, here is the content of the file
files/listing.rs
// listing.rs
use crate::Result;
pub fn list_files(path: &str) -> Result<Vec<String>> {
let files: Vec<String> = std::fs::read_dir(path)?
.filter_map(|re| re.ok())
.filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.collect();
if files.is_empty() {
return Err("Cannot list empty folder.".into());
}
Ok(files)
}
- At the top of the file the line
use crate::Result;is a shortcut. I can writeResult<T>rather thancrate::Result<T>.- We know that the module
listingis a grand-child of the crate root (check the module tree). - This said, if we recall that the visibility rule says that a private item is visible in the curent module and in all its children modules
- This explains why
crate::Resultis accessible in the modulelisting
- We know that the module
- I had to add the
pubaccess specifier at the beginning of the linelist_files()so that the function can be called from the grand-parent module (crate root inmain.rs) - Other than that, there is no change
Once the code is dispatched and organized as explained I can open a terminal (CTRL+ù on a FR keyboard) at the root of the workspace and run it with:
cargo run -p step_00
Here is what I can see in VSCode:

- In
main(),my_lib::list_files()is called with an argument which is a path to an empty directory. No surprise, we print a message and the application exit.
To confirm my understanding, I did some tests.
🦀 Test 1:
- In
listing.rsabove, I comment the lineuse crate::Result; - I modify the signature of
list_files()
pub fn list_files(path: &str) -> crate::Result<Vec<String>> {...}
- I can build the project
- This confirms that
use crate::Result;is nothing more than a shortcut - I delete the modifications
🦀 Test 2:
- In
main.rs, in front of theResultandErrortype alias declarations I remove thepubaccess specifier - I can build the project.
- Why? Because we are building a binary crate, nothing is accessed from the outside and so, in this context,
pubdoes’nt hurt but is useless. - Then I put the
pubback in place because they seem important to you.
Path to Production - Step_01
Bob: The second step should be easy. Create an error.rs file then copy/paste Result and Error definitions there. Explain what you do when you make the code runs as before.
Alice: You know what? I copy/paste/rename the previous package in a directory named 01_project.
- I update the package name in
Cargo.toml(name = "step_01") - I create an
error.rsfile with this content:
// error.rs
pub type Result<T> = std::result::Result<T, Error>;
pub type Error = Box<dyn std::error::Error>;
- I update the content of
main.rs
// main.rs
mod error;
mod files;
use crate::files::listing;
use crate::error::Result;
fn main() -> Result<()> {
let files = listing::list_files(".")?;
println!("{files:#?}");
let files = listing::list_files("./02_production/01_project/empty")?;
println!("{files:#?}");
Ok(())
}
- The line
mod error;declares the existence and loads a module namederrorin the binary crate. It includes the content of the module found in the external fileerror.rs - Since the line
mod error;appears in the crate root, theerrormodule is a child of the latter. Now, the module tree looks like this:
crate The crate root module is stored in main.rs
| error The `error` module is stored in error.rs
└─ files The `files` module is stored in files.rs
└─ listing The `listing` module is stored in files/listing.rs
Side Note
cargo install cargo-modulescargo-modules structure --package step_01

-
I also add
use crate::error::Result;so that I can writefn main() -> Result<()> {...}rather thanfn main() -> error::Result<()> {...} -
For the “fun”, now in
main()we inspect 2 different directories.
Here is what I can see in the terminal

Again I check my understanding about what is possible and what is not with the modules and the module tree.
🦀 Test 3:
- The module
erroris a child of the crate root (inmain.rs). - Within
errorI can “see” what is in the crate root but the crate root cannot “see” what is inerrorif I don’t make it public. - So in
error.rsI remove thepubaccess modifier in front ofResult. - Then I check I’m no longer able to build the project.
- I undo my modifications
🦀 Test 4:
- In
main.rsI comment the lineuse crate::error::Result;and I writefn main() -> crate::error::Result<()> {. - I cannot build the crate because there is a problem with
use crate::Result;inlisting.rs(unresolved import 'crate::Result'). - Obviously, if in
listing.rsI replace the lineuse crate::Result;withuse crate::error::Result;then I can build again because inerror,Resultis public.
Now, the 1 million $ question is: “why it worked in the initial version?”
The answer goes like this:
Resultis public in theerrormodule- In the crate root we have
mod errorwhich bringserror::Resultis in the current scope, the scope of the crate root. - If
Resultis in the scope of the crate root, it becomes visible from its children - So
crate::Resultis available in the scope oflistingmodule (child visibility rule) - In the
listingnamespace I can write:pub fn list_files(path: &str) -> crate::Result<Vec<String>> {...- or
use crate::Result;thenpub fn list_files(path: &str) -> Result<Vec<String>> {...

Tadaa!
It took me a while. Believe me, it’s harder to write and explain than to make the changes in the code, but honestly, it’s worth it. I realize how important it is to have the module tree in mind (or to print it as shown before) and to know the rule of visibility.
Bob: I’m genuinely impressed by your insight and your willingness to test ideas to strengthen your understanding. Keep it up!
You will be happy to learn that in the next step, you will create a library and expose an API… Welcome to the real world

Create a lib.rs file at the root of the project, put the pub mod error; and pub mod files;. Make the application run again and, as before, explain what you do.
Path to Production - Step_02
Alice: Um… Ok… I start with a copy/paste/rename of the previous directory (02_project/)
- I recall that if in the directory there is a
lib.rsand amain.rs- The build system builds the library crate then the binary crate.
- The lib is automatically linked to the binary.
- Ideally I want to keep
main()as short as possible. It should validate some stuff then call arun()function from the library. - Here I will keep
list_file()in main as before.
Once the copy of the directory is done:
- I update the package name in
Cargo.toml(name = "step_02") - I create a
lib.rsfile with this content:
// lib.rs
pub mod error;
pub mod files;
// re-export lib from crate root
pub use self::error::{Error, Result};
- Since I want to call
list_files()frommain()I “put” thefilesmodule in thelib main()returns aResult<()>so I “put” the error module in thelibas well-
So far there is no need to copy/paste the code from
files/listings.rsintolib.rs. Indeed if tomorrow the app grows, I will write more code in more modules and I will simply list the modules inlib.rs. - At this point, if I compare V2 on the left versus V1 of the file
main.rshere is what I can see:

- The lines
mod error;andmode files;have been moved tolib.rs. The moduleserrorandfilesare now in the lib namespace. - One point of attention: In previous versions, the code was monolithic, all the modules were children of the same root, all symbols were accessible within the same namespace. This is why, in
main.rs, a line likeuse crate::files::listing;allowed us to calllisting::list_files().cratewas pointing to the crate being built, the binary crate. - But this is no longer the case. Indeed
list_files()is now in the library namespace. - This is why, since the library is linked to the binary I need to write
use step_02::files::listing;wherestep_02is the name of the library (which is the same as the name of the binary. I know, this does’nt help much…)
And that’s it. It builds and run like a charm…
Bob: This is cool but I have 2 questions for you. First question: are you sure when you say that in use step_02::files::listing;, step_02 is the name of the library. I believe you, but how can we remove any doubt?
Alice: We can modify Cargo.toml as shown below:
[package]
name = "step_02"
version = "0.1.0"
edition = "2024"
[dependencies]
[[bin]]
name = "my_app" # name of the executable (my_app.exe under WIN11)
path = "src/main.rs"
[lib]
name = "my_super_lib" # name of the lib (libmy_super_lib.rlib under WIN11)
path = "src/lib.rs"
Then we can modify main.rs:
// main.rs
use my_super_lib::Result;
use my_super_lib::files::listing;
fn main() -> Result<()> {
let files = listing::list_files(".")?;
println!("{files:#?}");
let files = listing::list_files("./02_production/01_project/empty")?;
println!("{files:#?}");
Ok(())
}
Here is the output in the console

The runtime mention my_app.exe when it says something like: process didn't exit successfully: target\debug\my_app.exe while in main.rs we write use my_super_lib::files::listing;.
One last point of attention if I can… The command to build and run the application remains: cargo run -p step_02. This is because step_02 is the name of the package in Cargo.toml. Review the content of Cargo.toml if this is not crystal clear.
What is your second question?
Bob: Easy, Padawan, I think the Force is making your head a little bigger… My second question is about the last line of the lib.rs:
pub use self::error::{Error, Result};
You did’nt say a word about it while in the last screenshot above I see the following comment:
use step_02::Result; // uses the re-export from the lib.rs
Would you be so kind as to explain to an 800-year-old Jedi why you wrote these lines of code and these comments?
Alice: You’re right it took me a while so they deserve some explanations.
- In
lib.rs- I load the modules
errorandfilesin the module tree - If the line
pub use self::error::{Error, Result};is commented I can’t build the package. I get an error fromlisting.rssaying:
use crate::Result; ^^^^^^^^^^^^^ no `Result` in the root- I’m not impressed. I know the module tree of the library crate, I can fix the problem and build the library. In
listing.rsI writeuse crate::error::Result;rather thanuse crate::Result; - However, if building the library seems OK, I can’t build the binary crate. I see an error in
main.rs:
use step_02::Result; ^^^^^^^^^^^^^^^ no `Result` in the root- Again, I know the module tree of the binary crate. In
main.rsI writeuse step_02::error::Result;rather thanuse step_02::Result; - Then I can build the package (the library crate and the binary crate)
- However…
- This work here because I have few modules. What if I have hundreds?
- On the other hand, I don’t like the idea of not being able to reuse most of the source code from the previous version without modifying it.
- This is where the re-export “trick” enters the game.
- In
lib.rsI load the modules I need (errors,files) - Then I create, at the top of the module tree of the library crate, the shortcuts I need:
pub use self::error::{Error, Result}; - With this, 2 things happens
- With
use self::error::{Error, Result};all child modules of the library crate can useResultas if it was declared at the top of the module tree (I can writecrate::Resultinstead ofcrate::error::Result). This is what is done inlisting.rs - With the
pubaccess specifier,ResultandErrorare accessible from code linked with the library. The binary crate of the package for example. This is why inmain.rsI first create a shorcut to Result in the library (seeuse step_02::Result;) and then use it locally (seewrite fn main() -> Result<()> {...}).
- With
- In
- I load the modules
Bob: Not to split hairs here, but if the shortcut lives in lib.rs, why duplicate it in main.rs?
Alice: This is exactly what I did but it does’nt work. Indeed without the shortcut use step_02::Result; in main.rs, this is the Result<T,E> from the std lib which is used and the compiler is not happy. See by yourself:
fn main() -> Result<()> {
^^^^^^ -- supplied 1 generic argument
|
expected 2 generic arguments
So in main.rs the shortcut “overwrite” the default Result<T, E>
Bob: One last question. In lib.rs you write pub use self::error::{Error, Result}; while until now you have been using use crate quite a lot. Is there any specific reason.
Alice: Absolutely none.
- In a path like
self::error::Error,selfrefers to the current module. This allows to use paths relative to the current module. Since we are inlib.rs,selfrefers to the crate root. - In a path like
crate::error::Error,craterefers to the crate root. This allow to use absolute path, always starting from the root. - So here both paths are equivalent. I’ll keep the absolute version.
Path to Production - Step_03
Bob: Splendid! Did you notice we did’nt yet talk about error handling? First, let’s set the problem then we will work on one possible option.
- I let you copy/paste/rename of the previous directory (
03_project) - Think to update the package name in
Cargo.toml(name = "step_03")
Once this is done, to “feel” the problem, please modify the main() function as below then run the code and tell me what you think.
fn main() -> Result<()> {
let files = listing::list_files(".")?;
println!("{files:#?}");
// let files = listing::list_files("./02_production/03_project/empty")?;
// println!("{files:#?}");
let files = listing::list_files("./non_existent_folder")?;
println!("{files:#?}");
Ok(())
}
Alice: Here is what I see

-
We use to read
Error: "Cannot list empty folder.". This message was coming fromlisting.rswhen the code detects that there is no file to list. See below:// listing.rs use crate::Result; pub fn list_files(path: &str) -> Result<Vec<String>> { let files: Vec<String> = std::fs::read_dir(path)? .filter_map(|re| re.ok()) .filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false)) .filter_map(|e| e.file_name().into_string().ok()) .collect(); if files.is_empty() { return Err("Cannot list empty folder.".into()); } Ok(files) } list_files()is not ready to handle cases were the directory does not exists. In such case, whenread_dir()returns, the?operator bubbles up the error tomain()- Back in
main(), the error is returned asBox<dyn std::error::Error> - Finally, the Rust runtime prints the last 2 messages.
Bob: Any comment?
Alice: The app is not ready to handle all possible kinds of errors it may encounter. In the experimentation phase it was acceptable but, in production phase it is no longer the case. We need to put in place a scalable errors management but I have no idea how to do that…
Bob: You’re right. The app is not yet ready but don’t worry, solutions based on custom errors exist (do you remember the enum etc.?).
We will keep our methodology and make one step at a time. Based on our experience in list_files(), in a first step we will make sure the app can report all kind of I/O errors as well as custom error messages based on strings of chars (String or string literal). Let me show you how…
So far errors.rs look like this:
// error.rs
pub type Result<T> = std::result::Result<T, Error>;
pub type Error = Box<dyn std::error::Error>;
Modify it so that it looks like that:
// error.rs
use thiserror::Error;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Error)]
pub enum Error {
#[error("custom error: {0}")]
Custom(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
impl Error {
pub fn custom(val: impl std::fmt::Display) -> Self {
Self::Custom(val.to_string())
}
}
impl From<&str> for Error {
fn from(val: &str) -> Self {
Self::Custom(val.to_string())
}
}
Do not look at the code yet but realize that since:
- the
error.rsfile is standalone pub type Error = Box<dyn std::error::Error>;is on its own line
We can now change the implementation of Error without impacting the rest of the project thanks to the level of indirection.
Now, in the code above, only pay attention to Error.
- So far its datatype was
Box<dyn std::error::Error> - Important: Now it is an
enum. This means that the lib restrict itself to only 2 flavors:Error:CustomorError::Io - It is a totally different story
- We used to be more lax, free, and flexible (
Box<dyn std::error::Error>), but now we’re becoming stricter, more specific, and more professional in the way we handle errors (pub enum Error{...}).
If needed, open a console and add thiserror crate to Cargo.toml with the command below:
cargo add thiserror --package step_03
Cargo.toml should look like:
[package]
name = "step_03"
version = "0.1.0"
edition = "2024"
[dependencies]
thiserror = "2.0.17"
Now, run the application (cargo run -p step_03) and “tell me why” you get this output.
Alice: Good news, it works. Bad news, I don’t see big difference in the output. With step_02 I had:
[
".gitignore",
"Cargo.lock",
"Cargo.toml",
"README.md",
]
Error: Os { code: 3, kind: NotFound, message: "Le chemin d’accès spécifié est introuvable." }
error: process didn't exit successfully: `target\debug\step_02.exe` (exit code: 1)
Now with step_03 I see:
[
".gitignore",
"Cargo.lock",
"Cargo.toml",
"README.md",
]
Error: Io(Os { code: 3, kind: NotFound, message: "Le chemin d’accès spécifié est introuvable." })
error: process didn't exit successfully: `target\debug\step_03.exe` (exit code: 1)
The difference is… Now it prints Error: Io(Os... instead of Error: Os.... Not sure it makes the app more production ready.
Path to Production - Step_04
Bob: This is OK. It is part of our journey… Copy/paste/rename the directory as 04_project and rename the package in Cargo.toml as step_04.
Now, modify main.rs as below:
// main.rs
use step_04::Result;
use step_04::files::listing;
fn main() -> Result<()> {
match listing::list_files(".") {
Ok(files) => println!("Files found : {files:#?}"),
Err(e) => println!("Error: {e}"),
}
match listing::list_files("./02_production/04_project/empty") {
Ok(files) => println!("Files found : {files:#?}"),
Err(e) => println!("Error detected: {e}"),
}
match listing::list_files("./non_existent_folder") {
Ok(files) => println!("Files found : {files:#?}"),
Err(e) => println!("Error detected: {e}"),
}
Ok(())
}
The non_existent_folder is “back in town”
In addition, the ? operator has disappeared. We use match after each call instead. Don’t, but if you run the code at this point you should not see big changes.
Open error.rs and replace the line #[error(transparent)] with #[error("**** I/O error: {0}")]. See below the code fragment:
pub enum Error {
#[error("Custom error - {0}")]
Custom(String),
// #[error(transparent)]
#[error("**** I/O error: {0}")]
Io(#[from] std::io::Error),
}
Now run the code (cargo run -p step_04). What do you see. What is you understanding?
Alice: Here is what I get in the terminal:
Files found : [
".gitignore",
"Cargo.lock",
"Cargo.toml",
"README.md",
]
Error detected: Custom error - ⛔ Cannot list empty folder.
Error detected: **** I/O error: Le chemin d’accès spécifié est introuvable. (os error 3)
And now I understand what happens!
-
First call: No problemo! Files are listed as before.

- Second call: The code reports a custom error with a message because the directory is empty. This is business as usual.
- Third call: This is an unhandled I/O error. The directory does not exists. After
read_dir(), the?operator bubbles the error as anError. The code must convert the I/O error into anError. I don’t know yet all the details but I rememberthiserrorgenerates the code for that and it seems it is using templated message"**** I/O error: {0}"because I can see the 4 stars in the console.
To make a long story short: Now, when the app encounter an unknown I/O error it reports it as an Error::Io.
Cool, it works. Now I can uncomment the line with transparent and run the application again. Here is what I see:

Bob: One question however. Could you read error.rs and tell me exactly what is going on here?
Alice: We already used thiserror, but, Ok, let me start from the beginning… Until Step_03 we had:
// error.rs
pub type Result<T> = std::result::Result<T, Error>;
pub type Error = Box<dyn std::error::Error>;
Now we want our Error being able to cover system-level I/O errors or send back custom error messages. We would like to write something like:
// error.rs
pub type Result<T> = std::result::Result<T, Error>;
pub enum Error {
Custom(String),
Io(std::io::Error),
}
This cannot work but thiserror can help. And below is equivalent to what we had within ex24.rs
// error.rs
use thiserror::Error;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Error)]
pub enum Error {
#[error("Custom error - {0}")]
Custom(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
However, here few lines of code have been added:
// error.rs
use thiserror::Error;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Error)]
pub enum Error {
#[error("Custom error - {0}")]
Custom(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
impl Error {
pub fn custom(val: impl std::fmt::Display) -> Self {
Self::Custom(val.to_string())
}
}
impl From<&str> for Error {
fn from(val: &str) -> Self {
Self::Custom(val.to_string())
}
}
In the implementation section of Error we define a “convenience” constructor (see custom(param)). This constructor accepts anything implementing Display (string literals, numbers…) and converts the parameter into a Error::Custom variant. This provides more flexibility because we can write Error::custom("foo") instead of manually allocating a String.
The last implementation is here to help us to write return Err("something went wrong".into()). Indeed, since we remember that in Rust if the trait From<A> for B exists, then we get the trait Into<B> for A for free, we define From<&str> for Error so that we get Into<Error> for &str.
Path to Production - Step_05
Bob: You know… We could go one step further… Indeed if we want to be more strick we should remove the Custom variant of the Error enum and only list the errors we deal with.
I let you copy/paste/rename the directory as 05_project and rename the package in Cargo.toml as step_05.
Now I propose to use this version of the error.rs file
// error.rs
use thiserror::Error;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Error)]
pub enum Error {
#[error("⛔ Cannot list an empty folder")]
CantListEmptyFolder,
#[error(transparent)]
Io(#[from] std::io::Error),
}
For comparison, find below the previous version:
// error.rs
use thiserror::Error;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Error)]
pub enum Error {
#[error("Custom error - {0}")]
Custom(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
impl Error {
pub fn custom(val: impl std::fmt::Display) -> Self {
Self::Custom(val.to_string())
}
}
impl From<&str> for Error {
fn from(val: &str) -> Self {
Self::Custom(val.to_string())
}
}
- The
Customvariant has been removed. This allow us to remove the constructorcustom()and theFromtrait implementation forError. - More important. We now have
CantListEmptyFolderwhich is a variant without associated data, unlike theIo(std::io::Error)variant, which contains astd::io::Errorobject. SoCantListEmptyFolderacts as a constant of typeError.
With this in place we can now modify listing.rs so that it no longer returns a custom message when the directory is empty but the Error::CantListEmptyFolder custom error code instead. See below:
// listing.rs
use crate::{Error, Result};
pub fn list_files(path: &str) -> Result<Vec<String>> {
let files: Vec<String> = std::fs::read_dir(path)?
.filter_map(|re| re.ok())
.filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.collect();
if files.is_empty() {
// return Err("⛔ Cannot list empty folder.".into());
return Err(Error::CantListEmptyFolder);
}
Ok(files)
}
In the code above, if we want to return Err(Error::CantListEmptyFolder) we need to bring crate::Error into the local scope. This is why we now have use crate::{Error, Result}; (vs use crate::Result;) at the beginning of the source code.
If you run the app (cargo run -p step_05), here is what you should see:

Now, if in main() you modify the match in charge of the empty folder as below :
match listing::list_files("./02_production/05_project/empty") {
Ok(files) => println!("Files found : {files:#?}"),
// Err(e) => println!("Error detected: {e}"),
Err(e) => println!("Error detected: {e:?}"),
}
You should get the following output in the console. The :? format specifier helps to see the CantListEmptyFolder error code in plain English.
Files found : [
".gitignore",
"Cargo.lock",
"Cargo.toml",
"README.md",
]
Error detected: CantListEmptyFolder
Error detected: Le chemin d’accès spécifié est introuvable. (os error 3)
Path to Production - Step_06
Alice: Could you show me how to add testing to my “production” code ?
Bob: Yes I can but I will only show you the code. To tell the truth I’m getting tired, testing is an subject on itself and I believe it is time to end this conversation.
The code below is in 02_production/06_project/src/files/listing.rs.
// listing.rs
use crate::{Error, Result};
pub fn list_files(path: &str) -> Result<Vec<String>> {
let files: Vec<String> = std::fs::read_dir(path)?
.filter_map(|re| re.ok())
.filter(|e| e.file_type().map(|ft| ft.is_file()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.collect();
if files.is_empty() {
return Err(Error::CantListEmptyFolder);
}
Ok(files)
}
#[cfg(test)]
mod test {
use super::*;
// ! cwd is 06_project/ NOT 018_err_for_blog_post/ (workspace)
#[test]
fn test_empty_folder() {
let result = list_files("./empty");
assert!(matches!(result, Err(Error::CantListEmptyFolder)));
}
#[test]
fn test_non_existing_folder() {
let result = list_files("./non_existent_folder");
match result {
Err(Error::Io(_)) => {} // ok, this is an I/O error
other => panic!("Expected Error::Io, got {:?}", other),
}
}
#[test]
fn test_current_folder_contains_expected_files_v1() {
let result = list_files(".").expect("Should list current directory");
assert_eq!(result, vec!["Cargo.lock", "Cargo.toml"]);
}
// Cannot be sure of the order => sort
#[test]
fn test_current_folder_contains_expected_files_v2() {
let mut files = list_files(".").expect("Should list current directory");
files.sort();
let mut expected = vec!["Cargo.lock".to_string(), "Cargo.toml".to_string()];
expected.sort();
assert_eq!(files, expected);
}
// Cannot be sure of the order
// Cannot be sure other files are not added
// Just checks both files are present
#[test]
fn test_current_folder_contains_expected_files_v3() {
let files = list_files(".").expect("Should list current directory");
assert!(files.contains(&"Cargo.toml".to_string()));
assert!(files.contains(&"Cargo.lock".to_string()));
}
}
Here is what you should see after: cargo test -p step_06

Summary – Experimentation to Production
Summary – Experimentation to Production
- We now have a good code template for our experimentation
- See the summary of Experimentation
- We learn how to split monolithic experimentation code into one or more files, including a
lib.rsand amain.rs- We know much more about the module tree
- “Hub files” which help to build the module tree and avoid
mod.rsfilesmod files;: declares the existence and loads a module namedfilesin the crate under construction- Shortcuts like:
use crate::files::listing;- The visibility rule (if in parent then visible in child)
cratevsselfin theusestatements- Create an independent
error.rs- The re-export “trick” in
lib.rsto shareResultandErrorfrom the lib crate to the binary cratethiserrorand the re-write oferror.rsto provide a custom error type to the lib.- Move from custom error messages to strick custom error codes
- Testing with our library error codes
Exercises – Experimentation to Production
- You have this code
use std::fs; fn main() { let content = fs::read_to_string("config.txt"); println!("{:?}", content); }Apply what we discussed in the section Experimentation and use the “template” of our toolbox.
- You have this code
fn main() -> Result<(), Box<dyn std::error::Error>> { let mut input = String::new(); std::io::stdin().read_line(&mut input)?; let n: u32 = input.trim().parse()?; println!("Factorial : {}", factorial(n)); Ok(()) } fn factorial(n: u32) -> u32 { match n { 0 | 1 => 1, _ => n * factorial(n - 1), } }- Structure this package by creating:
lib.rsfile with a math module.- A
math.rsfile containing thefactorial()function. - An
error.rsfile with a customResulttype. - Ensure that
main.rsuses the library and handles errors via the customResulttype.
Conclusion
It’s 6AM. Your sister is coming back home. You are “dead” but happy because you learnt a lot even if you are not sure you will keep everything in your mind. It is also time for you to go to bed…
You know what? Don’t touch your PC for, at least, one day. And if you want to watch kitten’s videos use your phone.

Then come back to theses posts, one at a time and try to make the exercices. You can also take one of your previous code, copy/paste/rename the directory and add some error management. If the code is a “toy” stick to what we saw in Experimentation section. Once this is done, play the game and use what we learnt about errors management in the Production section, split one of your package in multiple files and add your own custom error type to it.
Enjoy!
Webliography
- THE book
- You’re probably misusing unwrap in Rust
- Custom Errors
- Error Best Practices