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

Let's have a beginner-friendly conversation on Errors, Results, Options, and beyond.
Posts
Table of Contents
- Custom Error Types and Error Handling in Larger Programs
- When and Why to Use
anyhow
andthiserror
crates - Errors from Experimentation to Production
- Conclusion
- Webliography
Custom Error Types and Error Handling in Larger Programs
Alice: So far we’ve talked about using the built-in errors (like std::io::Error
or parsing errors). What about in bigger programs where different parts can error in different ways? How should I think about and then design my own error data types, if necessary?
Bob: As our Rust program grows, we might call many operations that can fail, potentially with different error types. We have a few choices:
- Use one catch-all error type everywhere (like
Box<dyn std::error::Error>
or a crate likeanyhow
in applications) to simplify things - Define our own custom error type (usually an
enum
) that enumerates all possible errors in our context, and convert other errors into our type.
Defining a custom error type is common in libraries, so that the library returns one consistent error type that our users can handle, instead of many disparate types.
Alice: How would a custom error look?
Bob: Usually as an enum
, you know, the Rust’s jewel of the crown. For example, imagine a program that needs to load a configuration file which is in JSON format. Things that could go wrong: file I/O could fail, or JSON parsing could fail. These are two different error types from std or crates (IO errors and parse errors). We might create an enum
type definition like this:
// ex17.rs
use serde::Deserialize;
use std::fmt;
use std::fs::{read_to_string, write};
use std::io::ErrorKind;
#[derive(Debug)]
enum ConfigError {
Io(std::io::Error),
Parse(serde_json::Error),
}
// Implement Display for our error to satisfy Error trait.
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Io(e) => write!(f, "I/O error: {e}"),
ConfigError::Parse(e) => write!(f, "Parse error: {e}"),
}
}
}
// Implement the standard Error trait for integration with other error tooling.
impl std::error::Error for ConfigError {}
ConfigError
is an enum (a sum type). A value of this type is exactly one of its variants at a time. Here it has two possible variants:Io(...)
— a variant that carries one payload of typestd::io::Error
Parse(...)
— a variant that carries one payload of typeserde_json::Error
- Keep in mind that each enum variant is also a constructor of an instance of the enum.
- Think about :
fn Io(e: std::io::Error) -> ConfigError{...}
- Think about :
This is key
Each enum variant is also a constructor of an instance of the enum.
- Then we implement the
Display
trait for the data typeConfigError
.- This is mandatory. In VSCode, if we hover the word
Error
fromimpl std::error::Error
we learn that to implement theError
trait forConfigError
, the later must implementDebug
andDisplay
.Debug
is easy. It is implemented automatically thanks to the directive#[derive(Debug)]
. Now, regardingDisplay
, for each variant of theenum
we explain how towrite!()
it so that they can print nicely.
- This is mandatory. In VSCode, if we hover the word
- Finally comes the empty implementation of
Error
forConfigError
. It is empty because the trait only have default methods which is the case here. In other words, the line officially registers our data type as a standard error, without any additional customization.
Side Note
If you don’t feel confident with traits you can read this series of posts.
- Next, when we write the function
load_config()
we make sure it returnsResult<Config, ConfigError>
. See below :
fn load_config(path: &str) -> Result<Config, ConfigError> {
let data = read_to_string(path).map_err(ConfigError::Io)?;
let cfg = serde_json::from_str::<Config>(&data).map_err(ConfigError::Parse)?;
Ok(cfg)
}
Now, fasten your seat belt and stay with me because what follows is a bit rock ‘n’ roll… In any case, it took me a while to really realize what was happening. Indeed, inside load_config()
, if something bad happen we convert the current error into ConfigError
with the help of .map_err()
. Here is how :
- If it fails,
std::fs::read_to_string
returns aResult<String, std::io::Error>
.map_err(ConfigError::Io)
is then executed- However, since you remember (you confirm, you remember) that each enum variant of
ConfigError
is also an initializer of the enum, when.map_err(ConfigError::Io)
is executed, it calls the functionConfigError::Io(e: std::io::Error) -> ConfigError
which constructs and returns aConfigError
- The
ConfigError
(which have the traitstd::error::Error
) is presented in front of the?
operator - The
?
operator bubbles up theConfigError
immediately since in our case we saidstd::fs::read_to_string
failed
-
The same mechanics is at work on the next line
- The caller of
load_config()
only have to handleConfigError
. Below we show a part of theload_or_init()
function. The idea is to focus on how this works from the caller point of view :
fn load_or_init(path: &str) -> Result<Config, ConfigError> {
match load_config(path) {
...
Err(ConfigError::Parse(e)) => {
eprintln!("Invalid JSON in {path}: {e}");
Err(ConfigError::Parse(e))
}
...
}
}
- This is a
match
on the value returned byload_config()
- If the pattern matches
Err(ConfigError::Parse(e))
, the.json
in invalid - The function bubbles up (
Err(...)
) the error to the caller (main()
here)
Let’s have a look at the main()
function.
fn main() -> Result<(), Box<dyn std::error::Error>> {
write("good_config.json", r#"{ "app_name": "Demo", "port": 8080 }"#)?;
write("bad_config.json", r#"{ "app_name": "Oops", "port": "not a number" }"#)?;
let cfg = load_or_init("bad_config.json")?;
println!("Loaded: {} on port {}", cfg.app_name, cfg.port);
Ok(())
}
- Note that
main()
returnsResult<(), Box<dyn std::error::Error>>
- This is cool because now we can use the
?
operator in the body of themain()
at the end of certain lines - Thanks to
Box<dyn std::error::Error>>
, it works even if the error data type fromwrite()
andload_or_init()
are different.
Expected output of the ex17.rs
with bad_config.json
:
Invalid JSON in bad_config.json: invalid type: string "not a number", expected u16 at line 1 column 44
Error: Parse(Error("invalid type: string \"not a number\", expected u16", line: 1, column: 44))
error: process didn't exit successfully: `target\debug\examples\ex17.exe` (exit code: 1)
Find below ex17.rs
complete source code because I hate partial source code in blog posts that usually never works.
- Feel free to copy/paste in Rust Playground
- In VSCode, set a breakpoint and take the time to go through the code line by line (F10).

Click the image to zoom in
// ex17.rs
use serde::Deserialize;
use std::fmt;
use std::fs::{read_to_string, write};
use std::io::ErrorKind;
#[derive(Debug, Deserialize)]
struct Config {
app_name: String,
port: u16,
}
#[derive(Debug)]
enum ConfigError {
Io(std::io::Error),
Parse(serde_json::Error),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Io(e) => write!(f, "I/O error: {e}"),
ConfigError::Parse(e) => write!(f, "Parse error: {e}"),
}
}
}
impl std::error::Error for ConfigError {}
fn load_config(path: &str) -> Result<Config, ConfigError> {
let data = read_to_string(path).map_err(ConfigError::Io)?;
let cfg = serde_json::from_str::<Config>(&data).map_err(ConfigError::Parse)?;
Ok(cfg)
}
fn load_or_init(path: &str) -> Result<Config, ConfigError> {
match load_config(path) {
Ok(cfg) => Ok(cfg),
Err(ConfigError::Io(ref e)) if e.kind() == ErrorKind::NotFound => {
let default = Config { app_name: "Demo".into(), port: 8086 };
// Map the write error to ConfigError so `?` compiles.
write(path, r#"{ "app_name": "Demo", "port": 8086 }"#).map_err(ConfigError::Io)?;
eprintln!("{path} not found, created with default config");
Ok(default)
}
Err(ConfigError::Io(e)) => {
eprintln!("I/O error accessing {path}: {e}");
Err(ConfigError::Io(e))
}
Err(ConfigError::Parse(e)) => {
eprintln!("Invalid JSON in {path}: {e}");
Err(ConfigError::Parse(e))
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
write("good_config.json", r#"{ "app_name": "Demo", "port": 8080 }"#)?;
write("bad_config.json", r#"{ "app_name": "Oops", "port": "not a number" }"#)?;
let cfg = load_or_init("bad_config.json")?;
println!("Loaded: {} on port {}", cfg.app_name, cfg.port);
Ok(())
}
Alice: Got it. So if I have a module that does some operation, I should define an error type in that module representing things that can go wrong there, and use ?
to convert sub-errors into it, then bubble up to main()
. That way, main()
just sees my module’s error type (or I convert it further to something else or to Box<dyn Error>
at the final boundary).
Bob: Exactly. Let’s do a quick mini-example of propagating an error from a module to main()
. Suppose we have a module math_utils
with a function that can fail:
// ex19.rs
mod math_utils {
// This module could be in a file math_utils.rs
#[derive(Debug)]
pub enum MathError {
DivisionByZero { numerator: f64 },
NegativeLogarithm { value: f64 },
}
impl std::fmt::Display for MathError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MathError::DivisionByZero { numerator } => write!(f, "Cannot divide {} by zero", numerator),
MathError::NegativeLogarithm { value } => write!(f, "Logarithm of negative number ({})", value),
}
}
}
impl std::error::Error for MathError {}
// Functions that return Result<_, MathError>
pub fn divide(a: f64, b: f64) -> Result<f64, MathError> {
if b == f64::EPSILON { Err(MathError::DivisionByZero { numerator: a }) } else { Ok(a / b) }
}
pub fn log10(x: f64) -> Result<f64, MathError> {
if x < 0.0 { Err(MathError::NegativeLogarithm { value: x }) } else { Ok(x.log10()) }
}
}
use math_utils::{divide, log10};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn run() -> Result<()> {
let my_log = log10(1024.0)?;
println!("Log10 is {:.3}", my_log);
let ratio = divide(10.0, 3.0)?;
println!("Ratio is {:.3}", ratio);
let bad_ratio = divide(5.0, 0.0)?;
println!("This won't print because of error above ({})", bad_ratio);
Ok(())
}
fn main() -> Result<()> {
if let Err(e) = run() {
eprintln!("Error: {}", e);
std::process::exit(42);
}
Ok(())
}
Expected output:
Log10 is 3.010
Ratio is 3.333
Error: Cannot divide 5 by zero
error: process didn't exit successfully: `target\debug\examples\ex19.exe` (exit code: 42)
If we run this :
main()
calls therun()
function- There is no problem with
log10()
- There is no problem with the first
divide()
- The second
divide()
returns anErr(MathError::DivisionByZero)
and the?
bubbles up the error to the caller - The
println!()
withbad_ratio
is never executed - Back in
main()
, “Ooops, division by zero” is printed, thanks toDisplay
implementation forMathError
- Just for the fun, at this point, we return 42 and exit.
We could also catch the error in main
with a match
instead, and print something custom. But the point was to illustrate bubbling the error from a module up to main()
. The key was to define MathError
and to use it consistently. Each function in the module returns MathError
on failure, and run()
and main()
can deal with MathError
.
Alice: I think I have a much better understanding error handling in Rust now. Thanks.
Bob: It’s a lot to take in at first, but once we get comfortable, we appreciate how Rust’s approach makes us think about errors up front. No more runtime surprises from unhandled exceptions. We decide what to do in each case. And keep in mind, for larger projects, there are crates like thiserror
to reduce error boilerplate, and anyhow
for quick-and-easy error handling in applications. Those can be handy, but the fundamentals of Result<T, E>
and ?
we covered are the building blocks of it all.
Summary – Custom Errors
Summary – Custom Errors
- Custom error types: We can define our own error type (often an
enum
because our error can only have a value at a time) to represent errors in our application or library. This allows us to consolidate different error sources (IO, parsing, etc.) into one type and make our functions return that. It improves API clarity. Callers deal with one error type and can match on its variants.- Implementing Error trait: By implementing
std::error::Error
(which means implementingfmt::Display
and having#[derive(Debug)]
), our error type becomes interoperable with the standard ecosystem. It lets us use trait objects (Box<dyn Error>
) if needed and makes our errors printable and convertible.- Converting errors: We use pattern matching or helper methods like
.map_err()
(or theFrom
trait implementations) to convert underlying errors into our custom error variants. The?
operator automatically convert errors if our custom error type implementsFrom
for the error thrown inside the function. This reduces a lot of manual code in propagating errors upward.
- Suppose we have an error
enum
ConfigError { Io(io::Error), Parse(ParseError) }
. If a function reading a config file encounters anio::Error
, we can do.map_err(ConfigError::Io)?
to turn it into our error type and return it. The same for parse errors. Now the function returnsResult<Config, ConfigError>
, and the caller only has to handleConfigError
.- Using
Box<dyn Error>
: In application code, if we don’t want to define lots of error types, we can useBox<dyn Error>
as a catch-all error type (since most errors in std lib implementError
). For example,fn main() -> Result<(), Box<dyn std::error::Error>>
allows us to use?
with any error that implementsError
and just propagate it. This is convenient, but in library code you’d usually favor a concrete error type so that the API is self-documented.
Exercises – Custom Errors
-
Define and Use a Custom Error: Create an enum
MyError
with variants for two different error scenarios (for example,MyError::EmptyInput
andMyError::BadFormat(std::num::ParseIntError)
). Implementstd::fmt::Display
forMyError
to provide human-readable messages. Then write a functionparse_nonempty_int(s: &str) -> Result<i32, MyError>
that returns an error if the input string is empty (EmptyInput
) or if parsing to int fails (BadFormat
). Use?
and appropriate conversions (map_err
) inside the function. Test it with various inputs (empty string, non-numeric, numeric). -
Combine Two Error Types: Suppose we have two functions
fn get_data() -> Result<String, io::Error>
andfn parse_data(data: &str) -> Result<Data, ParseError>
. Write a new functionfn load_data() -> Result<Data, LoadError>
whereLoadError
is our custom enum that has variants for IO and Parse errors. Inload_data
, callget_data()
andparse_data()
using?
, converting their errors intoLoadError
(we can implementFrom<io::Error>
andFrom<ParseError>
forLoadError
or usemap_err
). Then try usingload_data()
in amain
that prints different messages depending on which error occurred (hint: usematch e { LoadError::Io(e) => ..., LoadError::Parse(e) => ... }
). -
Error Propagation in Modules: Organize a small project with two modules:
network
anddatabase
. Innetwork
, create a functionfetch_data()
that might return a network-related error (we can simulate by just returning anErr
variant likeNetworkError::Offline
). Indatabase
, create a functionsave_data()
that might return a DB-related error (e.g.,DbError::ConnectionLost
). Then inmain
, write a functionrun()
that callsfetch_data
thensave_data
, propagating errors using?
. Define a combined error type (enum withNetwork(NetworkError), Database(DbError)
) to unify them forrun()
. Havemain
callrun()
and handle the unified error. This exercise will give we practice in designing error types and propagating across module boundaries.
When and Why to Use anyhow
and thiserror
crates
Alice: You mentioned external crates like anyhow
and thiserror
. When should I reach for them?
Bob: Short version:
anyhow
in binaries when we don’t need a public, fine-grained error type and just want easy error propagation with context.thiserror
in libraries when we need ergonomic custom error types without writing allimpl
forDisplay
,Error
, and conversions.
anyhow - binaries (mnemonic: A, B, C…Anyhow, Binaries)
anyhow
provides a type called anyhow::Error
which is a dynamic error type (like Box<dyn Error>
but with some extras such as easy context via .context(...)
). It’s great for applications where we just want to bubble errors up to main()
, print a nice message with context, and exit. Here is an example:
// ex20.rs
use anyhow::{Context, Result};
use std::fs;
// Result alias = Result<T, anyhow::Error>
fn run() -> Result<()> {
let data = fs::read_to_string("config.json").context("While reading config.json")?; // adds context if it fails
let cfg: serde_json::Value = serde_json::from_str(&data).context("While parsing JSON")?;
println!("Config loaded: {cfg}");
Ok(())
}
fn main() -> Result<()> {
run()
}
Expected output:
Error: While reading config.json
Caused by:
Le fichier spécifié est introuvable. (os error 2)
- Notice how adding
.context(...)
makes error messages much more actionable if something fails. - Otherwise, the key point to understand the previous code is to realize that
Result
is a type alias forResult<T, anyhow::Error>
.
Alice: OK… But could you show me how we should modify one of the previous code, you know, the one where we were reading JSON config file.
Bob: Ah, yes, you’re right. Let’s rework ex17.rs
to see the impact and benefices. Tadaa!:
// ex21.rs
use anyhow::{Context, Result};
use serde::Deserialize;
use std::fs::{read_to_string, write};
use std::io::{self, ErrorKind};
#[derive(Debug, Deserialize)]
struct Config {
app_name: String,
port: u16,
}
fn load_config(path: &str) -> Result<Config> {
let data = read_to_string(path).with_context(|| format!("failed to read config file: {path}"))?;
let cfg = serde_json::from_str::<Config>(&data).with_context(|| format!("failed to parse JSON in: {path}"))?;
Ok(cfg)
}
fn load_or_init(path: &str) -> Result<Config> {
match load_config(path) {
Ok(cfg) => Ok(cfg),
Err(err) => {
if let Some(ioe) = err.downcast_ref::<io::Error>() {
if ioe.kind() == ErrorKind::NotFound {
let default = Config { app_name: "Demo".into(), port: 8086 };
let default_json = r#"{ "app_name": "Demo", "port": 8086 }"#;
write(path, default_json).with_context(|| format!("failed to write default config to {path}"))?;
eprintln!("{path} not found, created with default config");
return Ok(default);
} else {
eprintln!("I/O error accessing {path}: {ioe}");
return Err(err);
}
}
if let Some(parsee) = err.downcast_ref::<serde_json::Error>() {
eprintln!("Invalid JSON in {path}: {parsee}");
return Err(err);
}
Err(err)
}
}
}
fn main() -> Result<()> {
write("good_config.json", r#"{ "app_name": "Demo", "port": 8080 }"#).context("writing good_config.json")?;
write("bad_config.json", r#"{ "app_name": "Oops", "port": "not a number" }"#).context("writing bad_config.json")?;
let cfg = load_or_init("bad_config.json")?;
println!("Loaded: {} on port {}", cfg.app_name, cfg.port);
Ok(())
}
Expected output of the ex21.rs
with bad_config.json
:
Invalid JSON in bad_config.json: invalid type: string "not a number", expected u16 at line 1 column 44
Error: failed to parse JSON in: bad_config.json
Caused by:
invalid type: string "not a number", expected u16 at line 1 column 44
error: process didn't exit successfully: `target\debug\examples\ex21.exe` (exit code: 1)
In VSCode, open ex21.rs
and ex17.rs
side by side and compare both contents. If you do so and rearrange the source code layout, here is what you should see:

ex17.rs on lhs, ex21.rs on rhs
ex21.rs
is shorter but this is not the point.ConfigError
and its implementations has disappear because it is no longer needed.- Pay attention to
.with_context()
inload_or_init()
.- It is similar to
.context()
and the string literals. - It takes a closure that returns a String.
- It is used here to dynamically
format!()
string with the value of a variable (path
).
- It is similar to
- Also note how the
.context(...)
inmain()
makes error messages much more actionable.
This is typically what we need in binaries. Ok, let’s read the code:
- In the initial version
ex17.rs
we hadfn load_config(path: &str) -> Result<Config, ConfigError> {...}
- Now we have
fn load_or_init(path: &str) -> Result<Config> {...}
whereResult
is a type alias so that the signature should be read asfn load_config(path: &str) -> std::result::Result<Config, anyhow::Error>
anyhow
implementFrom<E>
for allE
that implementstd::error::Error + Send + Sync + 'static
- If any error happen during
read_to_string()
then the?
operator converts the error fromstd::io::Error
toanyhow::Error
(idem forserde_json::Error
fromserde_json::from_str
)
Now the tricky part is in load_or_init()
:
- Its signature should be read as
fn load_or_init(path: &str) -> Result<Config, , anyhow::Error>
- On error, we must downcast the
anyhow::Error
and check if it is anio::Error
. If it is the case we check if it is anErrorKind::NotFound
… - This is not really fun, I agree.
- In fact I wanted to keep the logic of
load_or_init()
the same. Since it now receivesResult<Config, , anyhow::Error>
and not aResult<Config, ConfigError>
we have some work to do to retrieve the 3 kinds of error (not found, access, invalid json). - Concerning
main()
except the signature there is no change.
For libraries, we should avoid anyhow::Error
in our public API and prefer a concrete error type (possibly made with thiserror
) so that downstream users can match
on variants. Let’s talk about it now.
thiserror - libraries
thiserror
is a derive macro crate. Instead of manually implementing by hand Display
and Error
and writing From
conversions (remember Debug
comes with the directive #[derive(Debug)]
), we can do something concise like:
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error), // #[from] automatically implements From
#[error("JSON parse error: {0}")]
Json(#[from] serde_json::Error),
}
Now our load_config()
function can just use the ?
operator and the #[from]
converts sub-errors automatically. This is excellent for libraries, where we want to expose a stable and descriptive error type to users.
Alice: I really don’t like code snippet. I like to see all the code. ex17.rs
is a standalone binary. Could you show me, step by step, how you would split it as a library serving a binary?
Bob: Great idea. It is a good opportunity to see code refactoring in practice. Since you want to see all the code each time, I’ll need some space but this should not be a problem here.
First, let’s review ex17.rs
once again:
// ex17.rs
use serde::Deserialize;
use std::fmt;
use std::fs::{read_to_string, write};
use std::io::ErrorKind;
#[derive(Debug, Deserialize)]
struct Config {
app_name: String,
port: u16,
}
#[derive(Debug)]
enum ConfigError {
Io(std::io::Error),
Parse(serde_json::Error),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Io(e) => write!(f, "I/O error: {e}"),
ConfigError::Parse(e) => write!(f, "Parse error: {e}"),
}
}
}
impl std::error::Error for ConfigError {}
fn load_config(path: &str) -> Result<Config, ConfigError> {
let data = read_to_string(path).map_err(ConfigError::Io)?;
let cfg = serde_json::from_str::<Config>(&data).map_err(ConfigError::Parse)?;
Ok(cfg)
}
fn load_or_init(path: &str) -> Result<Config, ConfigError> {
match load_config(path) {
Ok(cfg) => Ok(cfg),
Err(ConfigError::Io(ref e)) if e.kind() == ErrorKind::NotFound => {
let default = Config { app_name: "Demo".into(), port: 8086 };
write(path, r#"{ "app_name": "Demo", "port": 8086 }"#).map_err(ConfigError::Io)?;
eprintln!("{path} not found, created with default config");
Ok(default)
}
Err(ConfigError::Io(e)) => {
eprintln!("I/O error accessing {path}: {e}");
Err(ConfigError::Io(e))
}
Err(ConfigError::Parse(e)) => {
eprintln!("Invalid JSON in {path}: {e}");
Err(ConfigError::Parse(e))
}
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
write("good_config.json", r#"{ "app_name": "Demo", "port": 8080 }"#)?;
write("bad_config.json", r#"{ "app_name": "Oops", "port": "not a number" }"#)?;
let cfg = load_or_init("bad_config.json")?;
println!("Loaded: {} on port {}", cfg.app_name, cfg.port);
Ok(())
}
Here is the content of the terminal
Invalid JSON in bad_config.json: invalid type: string "not a number", expected u16 at line 1 column 44
Error: Parse(Error("invalid type: string \"not a number\", expected u16", line: 1, column: 44))
error: process didn't exit successfully: `target\debug\examples\ex17.exe` (exit code: 1)
As you say, it is a standalone, all-included, kind of binary. So, as a first step, let’s split it into a library and a binary. For demo purpose, we can do this with a single file. In ex22.rs
(see below) we just define a module inside the source code. If needed, review what we did in ex19.rs
(the code with log10()
, do you remember?, September?).
Here is the code after the first step of refactorization:
// ex22.rs
mod my_api {
use serde::Deserialize;
use std::fmt;
use std::fs::{read_to_string, write};
use std::io::ErrorKind;
#[derive(Debug, Deserialize)]
pub struct Config {
pub app_name: String,
pub port: u16,
}
#[derive(Debug)]
pub enum ConfigError {
Io(std::io::Error),
Parse(serde_json::Error),
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::Io(e) => write!(f, "I/O error: {e}"),
ConfigError::Parse(e) => write!(f, "Parse error: {e}"),
}
}
}
impl std::error::Error for ConfigError {}
fn load_config(path: &str) -> Result<Config, ConfigError> {
let data = read_to_string(path).map_err(ConfigError::Io)?;
let cfg = serde_json::from_str::<Config>(&data).map_err(ConfigError::Parse)?;
Ok(cfg)
}
pub fn load_or_init(path: &str) -> Result<Config, ConfigError> {
match load_config(path) {
Ok(cfg) => Ok(cfg),
Err(ConfigError::Io(ref e)) if e.kind() == ErrorKind::NotFound => {
let default = Config { app_name: "Demo".into(), port: 8086 };
write(path, r#"{ "app_name": "Demo", "port": 8086 }"#).map_err(ConfigError::Io)?;
eprintln!("{path} not found, created with default config");
Ok(default)
}
Err(ConfigError::Io(e)) => {
eprintln!("I/O error accessing {path}: {e}");
Err(ConfigError::Io(e))
}
Err(ConfigError::Parse(e)) => {
eprintln!("Invalid JSON in {path}: {e}");
Err(ConfigError::Parse(e))
}
}
}
}
use my_api::load_or_init;
use std::fs::write;
fn main() -> Result<(), Box<dyn std::error::Error>> {
write("good_config.json", r#"{ "app_name": "Demo", "port": 8080 }"#)?;
write("bad_config.json", r#"{ "app_name": "Oops", "port": "not a number" }"#)?;
let cfg = load_or_init("bad_config.json")?;
println!("Loaded: {} on port {}", cfg.app_name, cfg.port);
Ok(())
}
Hopefully the output is exactly the same:
Invalid JSON in bad_config.json: invalid type: string "not a number", expected u16 at line 1 column 44
Error: Parse(Error("invalid type: string \"not a number\", expected u16", line: 1, column: 44))
error: process didn't exit successfully: `target\debug\examples\ex22.exe` (exit code: 1)
Now, concerning the refactoring we can observe:
- Obviously we now have a
mod my_api
at the top of the code - Think about it as “a file in a file”. This is not true but this can help.
- The
use my_api::load_or_init;
statement is a “shortcut” that helps to writeload_or_init("bad_config.json")
rather thanmy_api::load_or_init("bad_config.json")
.
Side Note
If you don’t feel 100% confident with modules, crates, files… You can read this post
ConfigError
is now public because it is part ofload_or_init()
which is public
In this first step of the refactoring the main idea was to split the code in 2:
my_api
module on one end- and a consumer of the API on the other.
Now that we have our library crate set up, let’s explore how to make use of the thiserror
crate. So now, we refactor ex22.rs
into ex24.rs
. Here it is:
// ex24.rs
mod my_api {
use serde::Deserialize;
use std::fs::{read_to_string, write};
use std::io::ErrorKind;
use thiserror::Error;
type Result<T> = std::result::Result<T, ConfigError>;
#[derive(Debug, Deserialize)]
pub struct Config {
pub app_name: String,
pub port: u16,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON parse error: {0}")]
Parse(#[from] serde_json::Error),
}
fn load_config(path: &str) -> Result<Config> {
let data = read_to_string(path).map_err(ConfigError::Io)?;
let cfg = serde_json::from_str::<Config>(&data).map_err(ConfigError::Parse)?;
Ok(cfg)
}
pub fn load_or_init(path: &str) -> Result<Config> {
match load_config(path) {
Ok(cfg) => Ok(cfg),
Err(ConfigError::Io(ref e)) if e.kind() == ErrorKind::NotFound => {
let default = Config { app_name: "Demo".into(), port: 8086 };
write(path, r#"{ "app_name": "Demo", "port": 8086 }"#)?;
eprintln!("{path} not found, created with default config");
Ok(default)
}
Err(ConfigError::Io(e)) => {
eprintln!("I/O error accessing {path}: {e}");
Err(ConfigError::Io(e))
}
Err(ConfigError::Parse(e)) => {
eprintln!("Invalid JSON in {path}: {e}");
Err(ConfigError::Parse(e))
}
}
}
}
use my_api::load_or_init;
use std::fs::write;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn main() -> Result<()> {
write("good_config.json", r#"{ "app_name": "Demo", "port": 8080 }"#)?;
write("bad_config.json", r#"{ "app_name": "Oops", "port": "not a number" }"#)?;
let cfg = load_or_init("bad_config.json")?;
println!("Loaded: {} on port {}", cfg.app_name, cfg.port);
Ok(())
}
- The code of the client (
main()
) remains unchanged. - Changes occurs in the API and the biggest one is in
ConfigError
enum
definition.
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON parse error: {0}")]
Parse(#[from] serde_json::Error),
}
- The directive
#[error...
and#[from...
make the macro generates concrete implementations at compile time, and then the?
inload_config()
uses those implementations via static conversions. - This is why we no longer need the
impl fmt::Display for ConfigError{...}
nor theimpl Error for ConfigError {}
. - The signature of
load_config()
can be simplified - Idem for the signature of
load_or_init()
. In addition themap_err()
can be removed.
At the end we have an API and a consumer. In the API, we delegate to thiserror
the writing of the implementations. I hope your understand the refactoring process that bring us from ex17.rs
to ex24.rs
one step after the other. I hope you enjoyed to read complete code at each step.
Summary – anyhow
& thiserror
Summary –
anyhow
&thiserror
anyhow
: Binaries. Dynamic error type with great ergonomics and.context(...)
for adding messages. Best for applications where we just want to bubble errors up and print them, not pattern-match
on them.use anyhow::{Context, Result}; use std::fs; fn run() -> Result<String> { let data = fs::read_to_string("Cargo.toml").context("while reading Cargo.toml")?; Ok(data) } fn main() -> Result<()> { let data = run()?; println!("Config loaded: {}", data); Ok(()) }
thiserror
: Libraries. Derive-based crate to build clear, typed error enums with minimal boilerplate. Best for libraries and public APIs where the caller needs to inspect error kinds.use thiserror::Error; #[derive(Debug, Error)] enum ConfigError { #[error("I/O error: {0}")] Io(#[from] std::io::Error), } fn load(path: &str) -> Result<String, ConfigError> { Ok(std::fs::read_to_string(path)?) // auto-converts into ConfigError::Io } fn main() -> Result<(), ConfigError> { let content = load("Cargo.toml")?; println!("Loaded: {}", content); Ok(()) }
- Don’t mix them blindly: We can use both in the same project (e.g., library crates with
thiserror
exposed, binary crate usinganyhow
internally), but try to keep public APIs typed and internal app code ergonomic.
Exercises – anyhow
& thiserror
-
Can you explain why in the API of
ex24.rs
we foundtype Result<T> = std::result::Result<T, ConfigError>;
while in the client’s code we havetype Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
-
Refactor to
thiserror
: Take our custom error enum from the previous exercise and replace the manualDisplay
/Error
implementations with a#[derive(Error)]
and#[error(...)]
attributes fromthiserror
. If we had conversions fromio::Error
orserde_json::Error
, add#[from]
to those variants and remove our manualFrom
impls. -
Add Context with
anyhow
: Write a small binary that reads a file and parses JSON, returninganyhow::Result<()>
. Add.context(reading file)
and.context(parsing JSON)
to the respective fallible operations. Run it with a missing file and with invalid JSON to see the difference in error messages with the added context. -
Design Choice: Given a project that has both a library crate (
my_lib
) and a binary crate (my_cli
) in a Cargo workspace, decide how we would structure error handling across both. Hint:my_lib
exposes typed errors withthiserror
, whilemy_cli
depends onmy_lib
and usesanyhow
inmain
to convertmy_lib::Error
intoanyhow::Error
using?
and print user-friendly messages.
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, 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 my project to production code.

Bob: Help you in this quest, I can. And since you already know almost everything you need ti 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 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.
enum
and related features are powerful, but I’m not sure I want to bother with them in my experimental code. - I also remember what we say. 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
anyhow
and 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 01 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
pub type 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. In fact since we want to use the same code from experimentation to production it is smarter to keep Error
and Result<T>
type aliases on 2 type alias declarations.
Doing so, even if in production, the Error
type evolve to something different (e.g. a custom error type) the Result
type will not be impacted (it will always refers to Error
) and this is exactly what we want.
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 vectornumbers
by 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
for
loop 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_val
is Ok I add its value to the runningsum
, otherwise… With the help of.unwrap()
the codepanic!()
- At the end of the loop,
sum
is 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 theResult
has 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
Result
is anOk(value)
,.map_err()
does nothing. The?
operator evaluates tovalue
and the execution continues - If the
Result
isErr(e)
,.map_err()
applies the closure toe
and 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&str
into aBox<dyn std::error::Error>
- The promotion from
&str
toBox<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
&str
toBox<dyn std::error::Error>
works because std lib includes an implementation of theFrom
trait 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
String
toBox<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
files
vector 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()
, thefiles
vector 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 B
then we getInto<B> for A
for free - Here this means
Into<Box<dyn Error> for &str
exists - Then the
static &str
is 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 B
exists, then we get the traitInto<B> for A
for free.

Bob: It’s showtime! Let’s move to production phase.
Production
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 (from the error management standpoint). Today it 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)
}
What would you do?
Alice: As explained in THE book, I would create a lib 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 the responsibilities.
Bob: Ok, this is your task. Create a new project which does exactly the same thing but organized around a main()
function using an API exposed by a library. Create the project in the 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, step by step…
Side Note
From now on, in the workspace, the projects discussed below are in the
02_production/
directory.
Alice : OK…
- I create a project 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
│ tooling.rs
│
└───tooling
my_lib.rs
- I create a directory named
empty
to make some test - In the
Cargo.toml
the project is namedstep_00
because 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]
- In
main.rs
I 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 tooling;
use crate::tooling::my_lib;
fn main() -> Result<()> {
let files = my_lib::list_files("./02_production/00_project/empty")?; // see the ? here
println!("{files:#?}");
Ok(())
}
- The type alias declaration for
Result
andError
remains the same - The line
mod tooling
declares the existence of a module namedtooling
in the crate. It includes the contents of the module from the external filetooling.rs
use crate::tooling::my_lib
is a shortcut. It imports themy_lib
into the current scope.- Rather than writing
tooling::my_lib::list_files()
, I can writemy_lib::list_files()
. - Alternatively I could write
use crate::tooling::my_lib::list_files
and calllist_files()
directly but I prefer to writemy_lib::list_files()
. Indeed, 6 months from now, the code will be easier to read and I will not have to remember wherelist_files()
is defined.
- Rather than writing
Side Note
If you don’t feel 100% confident with files, crates, modules… Before reading what follows, you should read this short dedicated post
- In the directory tree,
tooling.rs
is a hub file. I mean it is a short file that declares which modules exist at a given level (here it declaresmy_lib
one level lower)
// tooling.rs
pub mod my_lib;
- And now here is the content of
tooling/my_lib.rs
// my_lib.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;
imports theResult
type from the crate root into the current scope. This is what allowslist_files()
to return aResult<T>
- I add
pub
at the beginning oflist_files()
signature and there is no other 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 (or the root of the current project) 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.
Summary – Experimentation to Production
Summary – Experimentation to Production
derive_more
: …- …: …
Exercises – Experimentation to Production
- …
- …