Rust Notes: Option and Result
A practical guide to Rust's Option and Result types.
Rust Option and Result
Rust does not use null and exceptions as its primary way of representing absence and recoverable errors.
Instead, Rust gives us two fundamental enums:
1
2
Option<T>
Result<T, E>
They are everywhere in Rust code.
A good mental model is:
1
2
3
4
5
6
7
8
9
10
11
Option<T>
|
+-- Some(T) -> a value exists
|
+-- None -> no value
Result<T, E>
|
+-- Ok(T) -> operation succeeded
|
+-- Err(E) -> operation failed
Option is mainly about absence.
Result is about success or failure with an error value.
The standard library defines Option<T> as an optional value and Result<T, E> as the type used for returning and propagating errors.
1. Option<T>
The definition is essentially:
1
2
3
4
enum Option<T> {
Some(T),
None,
}
For example:
1
2
3
let name: Option<String> = Some(String::from("Amir"));
let missing_name: Option<String> = None;
The important point is that the compiler forces us to acknowledge that the value might not exist.
2. Why Option Instead of null?
In languages with null, this is possible:
1
2
3
4
5
String
|
+-- actual string
|
+-- null
The type itself does not necessarily tell you that null is possible.
Rust instead represents this explicitly:
1
Option<String>
which tells us:
1
2
3
String may exist
OR
String may not exist
This makes absence part of the type system.
3. Handling Option with match
The most explicit way to handle an Option is:
1
2
3
4
5
6
let name = Some("Amir");
match name {
Some(name) => println!("Name: {name}"),
None => println!("No name"),
}
The compiler makes sure both cases are handled.
4. if let
When you only care about one variant:
1
2
3
4
5
let name = Some("Amir");
if let Some(name) = name {
println!("Name: {name}");
}
You can also handle the other case:
1
2
3
4
5
if let Some(name) = name {
println!("Name: {name}");
} else {
println!("No name");
}
This is useful when a full match would be unnecessarily verbose.
5. while let
while let repeatedly matches a pattern:
1
2
3
4
5
let mut values = vec![1, 2, 3];
while let Some(value) = values.pop() {
println!("{value}");
}
The loop continues while the expression produces Some.
When it produces None, the loop ends.
6. unwrap()
One of the most recognizable methods is:
1
2
3
4
5
let value = Some(42);
let x = value.unwrap();
println!("{x}");
Result:
1
42
But:
1
2
3
let value: Option<i32> = None;
let x = value.unwrap();
causes a panic.
Conceptually:
1
2
3
4
5
Some(value).unwrap()
-> value
None.unwrap()
-> panic!
unwrap() should therefore be used when you genuinely know that the value cannot be absent, or when a panic is acceptable. The standard library documentation explicitly warns that unwrap panics on None.
7. expect()
expect() is similar to unwrap() but lets you provide a useful panic message:
1
2
3
let config = Some("config.toml");
let path = config.expect("configuration path must exist");
If the value is None, the panic message explains the assumption that was violated.
Prefer:
1
expect("configuration must exist")
over:
1
unwrap()
when a panic is genuinely appropriate.
8. unwrap_or()
Instead of panicking, provide a default:
1
2
3
let name = Some("Amir");
let value = name.unwrap_or("Unknown");
Result:
1
Amir
With None:
1
2
3
let name: Option<&str> = None;
let value = name.unwrap_or("Unknown");
Result:
1
Unknown
Conceptually:
1
2
3
4
5
Some(x).unwrap_or(default)
-> x
None.unwrap_or(default)
-> default
9. unwrap_or_default()
If the contained type implements Default:
1
2
3
let name: Option<String> = None;
let name = name.unwrap_or_default();
Result:
1
String::new()
For example:
1
2
3
let number: Option<i32> = None;
let number = number.unwrap_or_default();
Result:
1
0
10. unwrap_or_else()
Use a closure to calculate the fallback:
1
2
3
4
5
let name: Option<String> = None;
let name = name.unwrap_or_else(|| {
String::from("Unknown")
});
This is particularly useful when calculating the default is expensive.
Compare:
1
value.unwrap_or(expensive_operation())
with:
1
value.unwrap_or_else(|| expensive_operation())
The second version evaluates the fallback only when needed.
The standard library documents unwrap_or as eagerly evaluating its argument and unwrap_or_else as lazily evaluating the fallback closure.
11. map() on Option
map() transforms the value inside Some.
1
2
3
let name = Some("Amir");
let length = name.map(|name| name.len());
The result is:
1
Some(4)
The important property is:
1
2
3
4
5
6
7
8
9
10
11
Some(x)
|
| map(f)
v
Some(f(x))
None
|
| map(f)
v
None
So:
1
2
Some(10)
.map(|x| x * 2)
becomes:
1
Some(20)
while:
1
2
None
.map(|x| x * 2)
remains:
1
None
This is one of the most useful patterns in Rust.
12. map() Does Not Handle Failure
Suppose:
1
2
3
fn parse_number(s: &str) -> Result<i32, ParseIntError> {
s.parse()
}
You cannot use Option::map to flatten a function that itself returns an Option:
1
2
let result = Some("42")
.map(|s| find_number(s));
If find_number() returns Option<i32>, the result becomes:
1
Option<Option<i32>>
This is where and_then() becomes useful.
13. and_then() on Option
Suppose:
1
2
3
4
5
6
7
fn get_user(id: u32) -> Option<User> {
// ...
}
fn get_address(user: User) -> Option<Address> {
// ...
}
You can chain them:
1
2
let address = get_user(10)
.and_then(get_address);
The important difference is:
1
2
3
4
5
map:
T -> U
and_then:
T -> Option<U>
For example:
1
2
Some(10)
.map(|x| Some(x * 2))
produces:
1
Some(Some(20))
while:
1
2
Some(10)
.and_then(|x| Some(x * 2))
produces:
1
Some(20)
and_then() is therefore often described as flat-mapping an Option.
14. and_then() Creates a Pipeline
This pattern is extremely useful:
1
2
3
4
let result = get_user()
.and_then(get_account)
.and_then(get_permissions)
.and_then(get_configuration);
Conceptually:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
get_user()
|
v
Option<User>
|
| and_then
v
Option<Account>
|
| and_then
v
Option<Permissions>
|
| and_then
v
Option<Configuration>
If any step returns None, the remaining operations are skipped.
This gives us a clean “stop if unavailable” pipeline.
15. filter()
filter() keeps a Some only if the predicate returns true.
1
2
3
let number = Some(10);
let result = number.filter(|x| *x > 5);
Result:
1
Some(10)
But:
1
2
3
let number = Some(3);
let result = number.filter(|x| *x > 5);
produces:
1
None
Conceptually:
1
2
3
4
5
6
7
8
Some(x) + predicate true
-> Some(x)
Some(x) + predicate false
-> None
None
-> None
16. flatten()
Suppose you have:
1
let value: Option<Option<i32>> = Some(Some(42));
You can remove one level:
1
let value = value.flatten();
Result:
1
Some(42)
Examples:
1
2
3
4
5
6
7
8
Some(Some(42)).flatten()
-> Some(42)
Some(None).flatten()
-> None
None.flatten()
-> None
and_then() is often preferable when the nesting comes directly from a function call.
17. ok_or()
Sometimes you have an Option but need a Result.
For example:
1
let user = find_user(id);
where:
1
find_user(id) -> Option<User>
You can convert it:
1
2
let user = find_user(id)
.ok_or(MyError::UserNotFound)?;
The conversion is:
1
2
3
4
5
Some(value)
-> Ok(value)
None
-> Err(error)
This is extremely useful when moving from optional data into error handling.
18. ok_or_else()
ok_or_else() is the lazy version:
1
2
let user = find_user(id)
.ok_or_else(|| MyError::UserNotFound)?;
The error is constructed only if the Option is None.
Use:
1
ok_or(...)
when the error is cheap to construct.
Use:
1
ok_or_else(|| ...)
when constructing the error requires work.
19. Option and ?
The ? operator works with Option.
For example:
1
2
3
4
fn get_first_name(user: &User) -> Option<&str> {
let name = user.name.as_ref()?;
Some(name)
}
The behavior is:
1
2
3
4
5
Some(value)?
-> value
None?
-> return None
So:
1
2
3
fn get_name(user: &User) -> Option<&str> {
Some(user.name.as_str())
}
can use ? to propagate absence through a chain.
The Rust standard library describes ? for Option as returning None early when the value is absent.
20. ? Is Not the Same as unwrap()
This distinction is extremely important.
1
let x = value.unwrap();
means:
1
2
If failure:
panic
while:
1
let x = value?;
means:
1
2
If failure:
return the failure from this function
For Option:
1
2
3
4
5
unwrap()
None -> panic
?
None -> return None
For Result:
1
2
3
4
5
unwrap()
Err -> panic
?
Err -> return Err(...)
This is one of the most important differences in Rust error handling.
21. Result<T, E>
The definition is:
1
2
3
4
enum Result<T, E> {
Ok(T),
Err(E),
}
For example:
1
2
3
4
5
6
7
fn divide(a: i32, b: i32) -> Result<i32, String> {
if b == 0 {
Err(String::from("division by zero"))
} else {
Ok(a / b)
}
}
Now:
1
let result = divide(10, 2);
gives:
1
Ok(5)
while:
1
let result = divide(10, 0);
gives:
1
Err("division by zero")
22. Result vs Option
A useful rule:
1
2
3
4
5
Option<T>
"There may or may not be a value."
Result<T, E>
"The operation may succeed or fail, and I want to know why."
Examples:
1
Vec::get(0)
returns:
1
Option<&T>
because the element may simply not exist.
But:
1
"123".parse::<i32>()
returns:
1
Result<i32, ParseIntError>
because parsing can fail for a meaningful reason.
23. Handling Result with match
1
2
3
4
match divide(10, 2) {
Ok(value) => println!("Result: {value}"),
Err(error) => println!("Error: {error}"),
}
This explicitly handles both outcomes.
24. unwrap() on Result
1
let value = "42".parse::<i32>().unwrap();
If parsing succeeds:
1
42
If parsing fails:
1
"hello".parse::<i32>().unwrap();
the program panics.
Again:
1
2
3
4
5
Ok(value).unwrap()
-> value
Err(error).unwrap()
-> panic
25. expect() on Result
1
2
3
let value = "42"
.parse::<i32>()
.expect("input must be a number");
This is useful when the failure indicates a programming error or violates an invariant that you intentionally want to treat as unrecoverable.
26. map() on Result
Result::map() transforms the Ok value.
1
2
3
let result: Result<i32, &str> = Ok(10);
let result = result.map(|x| x * 2);
Result:
1
Ok(20)
But:
1
2
3
let result: Result<i32, &str> = Err("failed");
let result = result.map(|x| x * 2);
remains:
1
Err("failed")
Conceptually:
1
2
3
4
5
6
7
8
9
10
11
Ok(x)
|
| map(f)
v
Ok(f(x))
Err(e)
|
| map(f)
v
Err(e)
27. map_err()
This is one of the most important methods when working with errors.
map() transforms the success:
1
2
Result<T, E>
-> Result<U, E>
map_err() transforms the error:
1
2
Result<T, E>
-> Result<T, F>
Example:
1
2
let result = std::fs::read_to_string("config.toml")
.map_err(|error| MyError::Config(error));
The success value stays unchanged.
Only the error is transformed.
Conceptually:
1
2
3
4
5
6
7
8
9
10
11
Ok(value)
|
| map_err(...)
v
Ok(value)
Err(error)
|
| map_err(...)
v
Err(transformed_error)
The standard library specifically defines map_err as transforming the contained Err value while leaving Ok unchanged.
28. Why map_err() Is Useful
Suppose:
1
2
3
4
fn load_config() -> Result<Config, ConfigError> {
std::fs::read_to_string("config.toml")
.map_err(ConfigError::Io)
}
The file operation might produce:
1
std::io::Error
but your function wants:
1
ConfigError
map_err() converts between the error types.
This is especially common when building your own error hierarchy.
29. and_then() on Result
Result::and_then() is used to chain operations that themselves return Result.
Suppose:
1
2
3
4
5
6
7
fn parse_number(s: &str) -> Result<i32, ParseIntError> {
s.parse()
}
fn double(x: i32) -> Result<i32, MyError> {
Ok(x * 2)
}
You can chain fallible operations:
1
2
let result = parse_number("42")
.and_then(double);
Conceptually:
1
2
3
4
5
6
7
8
9
10
11
Ok(x)
|
| and_then(f)
v
f(x)
Err(e)
|
| and_then(f)
v
Err(e)
The function passed to and_then() must itself return a Result.
30. map() vs and_then()
This distinction is extremely important.
Suppose:
1
2
3
fn double(x: i32) -> Result<i32, MyError> {
Ok(x * 2)
}
Using map():
1
let result = Ok(10).map(double);
produces:
1
Ok(Ok(20))
because:
1
2
3
4
map:
T -> U
i32 -> Result<i32, MyError>
Therefore:
1
2
Result<i32, E>
-> Result<Result<i32, MyError>, E>
But with:
1
let result = Ok(10).and_then(double);
we get:
1
Ok(20)
because:
1
2
and_then:
T -> Result<U, E>
and it flattens the nested Result.
31. A Useful Pipeline
This is a very common Rust pattern:
1
2
3
4
let value = read_file()
.map_err(MyError::Io)
.and_then(parse_config)
.and_then(validate_config);
Think of it as:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
read_file()
|
v
Result<String, IoError>
|
| map_err
v
Result<String, MyError>
|
| and_then
v
Result<Config, MyError>
|
| and_then
v
Result<ValidConfig, MyError>
If any operation produces Err, later operations are skipped.
32. or_else()
or_else() is useful when you want an alternative operation after failure.
For example:
1
2
let result = read_primary_config()
.or_else(|_| read_backup_config());
Conceptually:
1
2
3
4
5
Ok(value)
-> Ok(value)
Err(error)
-> call fallback function
Unlike and_then(), which continues on success, or_else() provides a path for failure.
33. map_or()
map_or() converts a Result into another value.
1
2
3
let result: Result<i32, &str> = Ok(10);
let value = result.map_or(0, |x| x * 2);
Result:
1
20
For:
1
2
3
let result: Result<i32, &str> = Err("failed");
let value = result.map_or(0, |x| x * 2);
Result:
1
0
Conceptually:
1
2
3
4
5
Ok(x)
-> f(x)
Err(_)
-> default
34. map_or_else()
Use map_or_else() when the fallback needs computation:
1
2
3
4
5
6
7
let value = result.map_or_else(
|error| {
println!("error: {error}");
0
},
|value| value * 2,
);
It provides:
1
2
Err -> error handler
Ok -> success handler
35. ok() and err()
You can convert a Result into an Option.
1
2
3
let result: Result<i32, &str> = Ok(42);
let value = result.ok();
Result:
1
Some(42)
For an error:
1
2
3
let result: Result<i32, &str> = Err("failed");
let value = result.ok();
Result:
1
None
err() does the opposite side:
1
2
3
let result: Result<i32, &str> = Err("failed");
let error = result.err();
Result:
1
Some("failed")
The standard library defines ok() as converting Ok(v) to Some(v) and Err(_) to None, while err() extracts the error into an Option.
36. Result and Option Conversion
These operations are useful:
1
2
3
4
5
6
7
Option<T>
|
+-- ok_or(...)
+-- ok_or_else(...)
|
v
Result<T, E>
And:
1
2
3
4
5
6
7
Result<T, E>
|
+-- ok()
+-- err()
|
v
Option<T>
This allows you to move between:
1
absence semantics
and:
1
error semantics
depending on what your function needs.
37. ? with Result
This is probably the most important Rust error-handling syntax.
Without ?:
1
2
3
4
5
6
7
8
fn load_config() -> Result<String, std::io::Error> {
let result = std::fs::read_to_string("config.toml");
match result {
Ok(value) => Ok(value),
Err(error) => Err(error),
}
}
With ?:
1
2
3
4
5
fn load_config() -> Result<String, std::io::Error> {
let value = std::fs::read_to_string("config.toml")?;
Ok(value)
}
The ? operator propagates the error.
The standard library documentation describes this as returning the Err early from the enclosing function and unwrapping the Ok value otherwise.
38. What ? Conceptually Does
This:
1
let value = operation()?;
can be thought of roughly as:
1
2
3
4
let value = match operation() {
Ok(value) => value,
Err(error) => return Err(error),
};
So:
1
2
3
4
5
operation()
|
+-- Ok(value) -----> continue
|
+-- Err(error) ----> return Err(error)
This is why ? makes error propagation so concise.
39. ? Can Convert Error Types
One of the powerful aspects of ? is that error conversion can happen when the target error type supports the necessary conversion.
For example:
1
2
3
4
fn load() -> Result<Config, MyError> {
let text = std::fs::read_to_string("config.toml")?;
// ...
}
If:
1
std::io::Error
can be converted into:
1
MyError
then ? can perform that conversion.
A common approach is to implement:
1
2
3
4
5
impl From<std::io::Error> for MyError {
fn from(error: std::io::Error) -> Self {
MyError::Io(error)
}
}
Then:
1
let text = std::fs::read_to_string("config.toml")?;
can propagate the converted error.
40. ? Does Not Mean “Ignore Errors”
This is an important misconception.
This:
1
let value = operation()?;
does not mean:
1
ignore the error
It means:
1
2
3
4
5
if successful:
continue with the value
if failed:
return the error from this function
So ? is better understood as:
propagate failure upward.
41. Chaining ?
This is where Rust error handling becomes very clean:
1
2
3
4
5
6
7
8
9
fn load_config() -> Result<Config, MyError> {
let text = std::fs::read_to_string("config.toml")?;
let config: Config = toml::from_str(&text)?;
validate_config(&config)?;
Ok(config)
}
The flow is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
read file
|
+-- error -> return
|
v
parse
|
+-- error -> return
|
v
validate
|
+-- error -> return
|
v
Ok(config)
This is one of the main reasons Rust error handling can remain readable even when many operations can fail.
42. ? with Option
The same idea works with Option:
1
2
3
4
5
6
fn get_port(config: &Config) -> Option<u16> {
let network = config.network.as_ref()?;
let port = network.port?;
Some(port)
}
The flow is:
1
2
3
4
5
Some(value)
-> continue
None
-> return None
43. ? with Both Option and Result
Sometimes you need to convert between them.
For example:
1
2
3
4
5
6
7
8
9
10
11
12
fn get_port(config: &Config) -> Result<u16, MyError> {
let network = config
.network
.as_ref()
.ok_or(MyError::MissingNetwork)?;
let port = network
.port
.ok_or(MyError::MissingPort)?;
Ok(port)
}
Here:
1
2
3
4
5
6
7
8
9
Option
|
| ok_or(...)
v
Result
|
| ?
v
value
This pattern is extremely common.
44. transpose()
Another useful conversion is transpose().
Suppose:
1
Option<Result<T, E>>
You can turn it into:
1
Result<Option<T>, E>
For example:
1
2
3
let value: Option<Result<i32, &str>> = Some(Ok(42));
let value = value.transpose();
Result:
1
Ok(Some(42))
This is useful when working with nested Option and Result values.
45. Result::transpose()
The opposite structure:
1
Result<Option<T>, E>
can become:
1
Option<Result<T, E>>
using:
1
transpose()
The key idea is that transpose() swaps the outer structure:
1
2
3
4
Option<Result<T, E>>
|
v
Result<Option<T>, E>
or:
1
2
3
4
Result<Option<T>, E>
|
v
Option<Result<T, E>>
46. inspect()
Sometimes you want to look at a successful value without changing it.
1
2
3
4
5
let result = "42"
.parse::<i32>()
.inspect(|value| {
println!("parsed value: {value}");
});
The original Result continues through the pipeline.
Similarly for Option:
1
2
3
4
let value = Some(42)
.inspect(|value| {
println!("value = {value}");
});
inspect() is useful for debugging pipelines.
47. inspect_err()
For Result, you can inspect an error without transforming it:
1
2
3
4
let result = operation()
.inspect_err(|error| {
eprintln!("operation failed: {error}");
});
The error remains unchanged.
This is useful for logging or debugging without breaking the pipeline.
48. Option Boolean-Style Combinators
Option has:
1
2
3
and()
or()
xor()
For example:
1
Some(10).and(Some(20))
produces:
1
Some(20)
while:
1
Some(10).and(None)
produces:
1
None
49. and_then() vs and()
The difference is that and() takes an already-created Option:
1
Some(10).and(Some(20))
while and_then() takes a closure:
1
2
3
Some(10).and_then(|x| {
Some(x * 2)
})
and_then() is lazy because the closure is only called when the original value is Some.
50. or() and or_else()
or() provides another Option:
1
None.or(Some(10))
produces:
1
Some(10)
or_else() computes the fallback lazily:
1
2
3
None.or_else(|| {
Some(10)
})
The pattern is:
1
2
3
4
5
and / and_then
-> alternative on success
or / or_else
-> alternative on absence/failure
51. Result Boolean-Style Combinators
Result also has:
1
2
3
4
and()
or()
and_then()
or_else()
For example:
1
Ok(10).and(Ok(20))
produces:
1
Ok(20)
while:
1
Ok(10).and(Err("failed"))
produces:
1
Err("failed")
And:
1
2
Err("primary failed")
.or_else(|_| Ok(42))
can recover using an alternative operation.
52. map vs map_err vs and_then vs or_else
This is one of the most important tables to remember:
| Method | Operates on | Function returns | Main purpose |
|---|---|---|---|
map | success/value | U | transform success |
map_err | error | F | transform error |
and_then | success/value | Result<U, E> / Option<U> | chain fallible operations |
or_else | error/absence | Result<T, F> / Option<T> | provide fallback |
For Result:
1
2
3
4
5
6
7
8
9
10
11
12
Result<T, E>
|
+-----------+-----------+
| |
Ok Err
| |
v v
map() map_err()
| |
v v
transform transform
success error
And:
1
2
3
4
5
6
7
8
9
10
11
12
Result<T, E>
|
+-----------+-----------+
| |
Ok Err
| |
v v
and_then() or_else()
| |
v v
continue with fallback
fallible operation operation
53. A Realistic Error Pipeline
Suppose we want to:
- read a file
- parse it
- validate it
We can write:
1
2
3
4
5
6
fn load_config() -> Result<Config, MyError> {
std::fs::read_to_string("config.toml")
.map_err(MyError::Io)
.and_then(|text| parse_config(&text))
.and_then(|config| validate_config(config))
}
Or, often more readably:
1
2
3
4
5
6
7
8
9
10
fn load_config() -> Result<Config, MyError> {
let text = std::fs::read_to_string("config.toml")
.map_err(MyError::Io)?;
let config = parse_config(&text)?;
let config = validate_config(config)?;
Ok(config)
}
Both approaches are valid.
The second version is often easier to read when the operations are substantial.
54. When Should I Use Combinators?
Combinators such as:
1
2
3
4
map()
map_err()
and_then()
or_else()
are particularly nice when the operation is naturally a pipeline:
1
2
3
4
5
let result = input
.map(parse)
.and_then(validate)
.map(transform)
.map_err(convert_error);
But don’t force everything into method chains.
This:
1
2
3
4
let config = read_file()?;
let config = parse(config)?;
validate(&config)?;
Ok(config)
is often clearer than a very long chain.
Rust gives you both styles.
55. unwrap() vs ?
A useful rule:
Use:
1
unwrap()
when:
“If this fails, something is fundamentally wrong and I intentionally want to panic.”
Use:
1
?
when:
“If this fails, this function cannot continue, so I want to return the error to my caller.”
For example:
1
let config = read_config().unwrap();
means:
1
2
I expect this can never fail.
If I'm wrong -> panic.
while:
1
let config = read_config()?;
means:
1
2
3
This operation can fail.
I don't handle the error here.
I'll propagate it to my caller.
56. unwrap() vs expect()
Prefer:
1
expect("config must exist")
over:
1
unwrap()
when you have a meaningful invariant.
For example:
1
2
3
let first = values
.first()
.expect("values must not be empty");
This communicates the assumption to someone reading the code.
57. Don’t Use unwrap() Everywhere
This is tempting:
1
2
3
let file = File::open("config.toml").unwrap();
let text = read_to_string(file).unwrap();
let config = parse(text).unwrap();
But now any failure causes a panic.
For application code, a better pattern is:
1
2
3
let file = File::open("config.toml")?;
let text = read_to_string(file)?;
let config = parse(text)?;
Then the caller decides how to handle the error.
58. Error Handling Philosophy
A useful principle is:
Handle an error where you have enough context to make a meaningful decision.
For example, a low-level function might return:
1
Result<Data, IoError>
instead of printing the error itself.
A higher-level function might decide:
1
2
3
4
5
6
match load_data() {
Ok(data) => process(data),
Err(error) => {
eprintln!("Failed to load configuration: {error}");
}
}
This keeps responsibilities separated.
59. Library vs Application Error Handling
For a library:
1
pub fn load() -> Result<Data, Error>
is often appropriate.
The library should usually return useful errors rather than immediately printing or terminating the process.
The application can then decide:
1
2
3
4
5
6
fn main() {
if let Err(error) = run() {
eprintln!("error: {error}");
std::process::exit(1);
}
}
Or:
1
2
3
4
fn main() -> Result<(), Box<dyn std::error::Error>> {
run()?;
Ok(())
}
60. if let Err(...)
A convenient way to handle only the error case:
1
2
3
if let Err(error) = operation() {
eprintln!("operation failed: {error}");
}
You don’t need to write a complete match when you don’t care about the successful value.
61. Result<(), E>
You will frequently see:
1
fn start_server() -> Result<(), ServerError>
Why ()?
Because the operation has no meaningful success value.
It means:
1
2
3
4
5
Ok(())
-> succeeded
Err(error)
-> failed
For example:
1
2
3
4
fn save_config(config: &Config) -> Result<(), IoError> {
// ...
Ok(())
}
62. Result<T, E> as a Contract
A function such as:
1
fn read_config() -> Result<Config, ConfigError>
communicates a lot:
1
2
3
4
5
The function may succeed:
-> Config
or fail:
-> ConfigError
This is much more informative than:
1
fn read_config() -> Config
and then unexpectedly panicking.
63. Option as a Contract
Similarly:
1
fn find_user(id: u64) -> Option<User>
communicates:
1
2
3
4
5
User exists
-> Some(User)
User does not exist
-> None
There is no need for an exception or null.
64. A Practical Decision Tree
When designing a function, ask:
1
2
3
4
5
6
Can the value legitimately be absent?
|
yes
|
v
Option<T>
If:
1
2
3
4
5
6
Can the operation fail?
|
yes
|
v
Result<T, E>
If you need to distinguish why it failed:
1
Result<T, E>
is usually preferable to:
1
Option<T>
65. Common Method Cheat Sheet
Option<T>
Inspect
1
2
3
is_some()
is_none()
is_some_and(...)
Extract
1
2
3
4
5
unwrap()
expect(...)
unwrap_or(...)
unwrap_or_else(...)
unwrap_or_default()
Transform
1
2
3
4
5
map(...)
map_or(...)
map_or_else(...)
filter(...)
flatten()
Chain
1
2
3
4
and(...)
and_then(...)
or(...)
or_else(...)
Convert
1
2
3
ok_or(...)
ok_or_else(...)
transpose()
Debug
1
inspect(...)
Propagate
1
?
66. Result<T, E> Cheat Sheet
Inspect
1
2
3
4
is_ok()
is_err()
is_ok_and(...)
is_err_and(...)
Extract success
1
2
3
4
unwrap()
expect(...)
unwrap_or(...)
unwrap_or_else(...)
Extract error
1
2
unwrap_err()
expect_err(...)
Transform success
1
2
3
map(...)
map_or(...)
map_or_else(...)
Transform error
1
2
map_err(...)
inspect_err(...)
Chain
1
2
3
4
and(...)
and_then(...)
or(...)
or_else(...)
Convert
1
2
3
ok()
err()
transpose()
Propagate
1
?
67. The Most Important Five Methods
If you are learning Rust, don’t try to memorize every method immediately.
Start with these:
1
2
3
4
5
map()
map_err()
and_then()
or_else()
?
Understand these deeply.
For example:
1
2
3
4
5
operation()
.map(transform_success)
.map_err(transform_error)
.and_then(next_operation)
.or_else(fallback_operation)?;
These methods form a large part of Rust’s functional-style error-handling patterns.
68. The Big Picture
Option:
1
2
3
4
5
6
7
Option<T>
|
+-------+-------+
| |
Some(T) None
| |
success absence
Result:
1
2
3
4
5
6
7
Result<T,E>
|
+-------+-------+
| |
Ok(T) Err(E)
| |
success failure
Transform success:
1
map()
Transform failure:
1
map_err()
Continue with another fallible operation:
1
and_then()
Try an alternative after failure:
1
or_else()
Propagate failure:
1
?
Extract with a panic:
1
2
unwrap()
expect()
Extract with a fallback:
1
2
3
unwrap_or()
unwrap_or_else()
unwrap_or_default()
Convert between Option and Result:
1
2
3
4
5
6
7
Option -> Result
ok_or()
ok_or_else()
Result -> Option
ok()
err()
69. A Final Example
Consider a configuration loader:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn load_port(path: &str) -> Result<u16, ConfigError> {
let text = std::fs::read_to_string(path)
.map_err(ConfigError::Io)?;
let config: Config = toml::from_str(&text)
.map_err(ConfigError::Parse)?;
let port = config
.server
.and_then(|server| server.port)
.ok_or(ConfigError::MissingPort)?;
if port == 0 {
return Err(ConfigError::InvalidPort);
}
Ok(port)
}
Notice how several concepts work together:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
read_to_string()
|
| map_err()
v
ConfigError
|
| ?
v
text
|
v
toml::from_str()
|
| map_err()
v
ConfigError
|
| ?
v
Config
|
| and_then()
v
Option<u16>
|
| ok_or()
v
Result<u16, ConfigError>
|
v
Ok(port)
This is the kind of code where Rust’s Option, Result, combinators, and ? really shine.
70. Key Takeaways
The most important mental model is:
1
2
3
4
5
Option<T>
= value may be absent
Result<T, E>
= operation may fail and provides an error
For transformations:
1
2
3
4
5
map()
transform the success value
map_err()
transform the error
For chaining:
1
2
3
4
5
and_then()
continue when successful
or_else()
provide a fallback when failed
For extraction:
1
2
3
4
5
6
7
8
9
10
11
unwrap()
panic on failure
expect()
panic with a useful message
unwrap_or()
use a fallback
unwrap_or_else()
calculate a fallback lazily
For propagation:
1
2
3
?
success -> continue
failure -> return failure
And the most important distinction to remember:
1
2
3
4
5
unwrap()
"I expect this cannot fail."
?
"This can fail, and my caller should handle it."
Rust’s Option and Result are not just containers. Together with methods such as map, map_err, and_then, or_else, and the ? operator, they form a composable model for representing, transforming, and propagating absence and failure without relying on exceptions or null values.