
Node to Rust, Day 13: Demystifying Results & Options
December 13, 2021
Introduction
I briefly touched on the Option
enum on Day 8 when we were using HashMap
methods to look up keys. There’s no way for the HashMap
to guarantee that a key will exist, so its API needs to account for returning something as well as nothing. Rust has no concept of undefined
or null
, like JavaScript. It needs to represent nothingness safely. That’s where the Option
comes in.
Nothingness is like an expected error case for functions that can return either something or nothing. try/catch
statements in JavaScript are another way to deal with reasonable error cases. In those cases the answer is either your result or uh oh, something went wrong. That’s what a Result
is. Result
and Option
go hand in hand. They are treated similarly and can be converted from one to another when necessary.
Quick links
- Day 1: From nvm to rustup
- Day 2: From npm to cargo
- Day 3: Setting up VS Code
- Day 4: Hello World (and your first two WTFs)
- Day 5: Borrowing & Ownership
- Day 6: Strings, part 1
- Day 7: Syntax and Language, part 1
- Day 8: Language Part 2: From objects and classes to HashMaps and structs
- Day 9: Language Part 3: Class Methods for Rust Structs (+ enums!)
- Day 10: From Mixins to Traits
- Day 11: The Module System
- Day 12: Strings, Part 2
- → Day 13: Results & Options
- Day 14: Managing Errors
- Day 15: Closures
- Day 16: Lifetimes, references, and
'static
- Day 17: Arrays, Loops, and Iterators
- Day 18: Async
- Day 19: Starting a large project
- Day 20: CLI Arguments & Logging
- Day 21: Building and Running WebAssembly
- Day 22: Using JSON
- Day 23: Cheating The Borrow Checker
- Day 24: Crates & Tools
The code in this series can be found at wasmflow/node-to-rust
This guide is not a comprehensive Rust tutorial. This guide tries to balance technical accuracy with readability and errs on the side of “gets the point across” vs being 100% correct. If you have a correction or think something needs deeper clarification, send a note on Twitter at @jsoverson, @candle_corp, or join our Discord channel.
Option
recap
If you’ve gotten this far without exploring enums, you are going to need to brush up quickly with the links in the additional reading section.
Rust enums differ from many other language implementations. They can represent rich and varied values and carry around behavior just like any struct. The Option
enum is defined in just a few lines here:
pub enum Option<T> {
/// No value
None,
/// Some value `T`
Some(T),
}
That’s it. It’s either Option::None
and contains no value, or Option::Some(T)
which contains a T
. We touched on generic functions briefly yesterday and you can find more information in that day’s additional reading section. This is how generic types look on data structures. Option
doesn’t care what T
is, there are no constraints.
Creating and returning an Option
is as easy as it gets in Rust.
fn main() {
let some = returns_some();
println!("{:?}", some);
let none = returns_none();
println!("{:?}", none);
}
fn returns_some() -> Option<String> {
Some("my string".to_owned())
}
fn returns_none() -> Option<String> {
None
}
[snipped]
Some("my string")
None
We still need to specify the value of
T
in theOption<T>
even if we return aNone
.
We can use
Some
&None
rather thanOption::Some()
andOption::None
because they are pre-imported by Rust’sprelude
(the common set of imports that you can always rely on). Read up on thestd::prelude
here.
Result
Result
is very similar to Option
except its failure case also contains a value. The value in the Result::Err
variant usually follows some conventions, but you can see by its implementation it has no constraints.
pub enum Result<T, E> {
Ok(T),
Err(E),
}
You can return your Ok()
or Err()
freely. There’s nothing special about how they are created.
fn main() {
let value = returns_ok();
println!("{:?}", value);
let value = returns_err();
println!("{:?}", value);
}
fn returns_ok() -> Result<String, MyError> {
Ok("This turned out great!".to_owned())
}
fn returns_err() -> Result<String, MyError> {
Err(MyError("This failed horribly.".to_owned()))
}
#[derive(Debug)]
struct MyError(String);
[snipped]
Ok("This turned out great!")
Err(MyError("This failed horribly."))
I used a custom struct to highlight that an
Err
can contain anything. A basic struct, your struct, aString
, someone else’s error, aHashMap
, whatever.
The problem with .unwrap()
Option
and Result
seem pretty straightforward, don’t they?
The confusion comes from how you actually get your damn value out. You’ve seen the .unwrap()
method in this guide and you’ve undoubtedly seen it all over Rust examples. You have probably also seen the warnings against using it. The warning comes for good reason. If you .unwrap()
a None
or Err
, your code will panic and your application will probably die. That’s no good.
If we wanted random application deaths due to failure cases we’d just write JavaScript. I’m kidding but also I’m not. With Rust, you can be 99% sure your application will be bullet-proof, as long as you respect its warnings. You’re not here to write working code fast, you’re here to write fast code that always works.
Unfortunately, respecting Rust’s warnings can be tedious and repetitive. That’s why you see shortcuts like .unwrap()
in examples. Example code is to get you up and running. It’s not there to show you how to handle every possible error case.
So how do I get my value out?
Here are your options:
.unwrap()
So now that you know you shouldn’t use .unwrap()
, here’s how you use .unwrap()
.
Use .unwrap()
when you’re sure you have a Some()
or an Ok()
. Look back at our IP address example from yesterday:
let ip_address = std::net::Ipv4Addr::from_str("127.0.0.1").unwrap();
I know that "127.0.0.1"
is a valid IPv4 address and will be parsed successfully. This is example code but it’s also OK in production. from_str()
needs to return a Result
because there are an infinite number of strings that won’t parse into an IPv4 address. I’m not passing it any of those.
The IP example relies on my knowledge of IP addresses. You can programmatically generate the confidence necessary to use .unwrap()
as well. You can use a HashMaps
’s .contains_key()
method to ensure there is a key. Then you’re free to .unwrap()
the resulting .get()
without fear.
That said, it’s valuable to always err on the side of caution. Rust won’t alert you if a refactor sidesteps your expectations.
.unwrap_or(value)
.unwrap_or()
is for providing a custom default value in the event of a failure message. The value needs to be the same type (T
) as Ok(T)
or Some(T)
.
let default_string = "Default value".to_owned();
let unwrap_or = returns_none().unwrap_or(default_string);
println!("returns_none().unwrap_or(...): {:?}", unwrap_or);
.unwrap_or_else(|| {})
.unwrap_or_else()
is nearly identical to .unwrap_or
except it takes a function. The return value of the function is used when the Option
or Result
is None
or Err
. You’d use this in situations where the default value might be expensive to compute and there’s no value computing it in advance.
As with .unwrap_or()
, the return type needs to be the same type of T
.
let unwrap_or_else = returns_none()
.unwrap_or_else(|| format!("Default value from a function at time {:?}", Instant::now()));
println!(
"returns_none().unwrap_or_else(|| {{...}}): {:?}",
unwrap_or_else
);
The
|| ...
syntax is Rust’s closure syntax.
In JavaScript/TypeScript, you’d have(arg1: number) => arg1 + 2
. In Rust it is|arg1: i64| arg1 + 2
. Curly braces are optional when there’s a single expression, just like in JavaScript. We’ll go over closures in more detail in a later section.
.unwrap_or_default()
.unwrap_or_default()
defers to a type’s Default
value if none exists. Default
is a trait like Debug
or Display
. It has one method, default
and takes no arguments. A type that implements Default
can be instantiated with [Type]::default()
. In other languages, you might consider this an implementation of the Null object pattern
. It’s what you can resort to when you need a neutral value of a type.
In TypeScript, you might do something like:
let my_string = maybe_undefined || "";
In Rust, it would be:
let my_string = maybe_none.unwrap_or_default(); // Assuming `T` is `String`.
You can implement
Default
like this:impl Default for MyStruct { fn default() -> Self { // Return whatever is suitable as a default. } }
Pattern matching
We can use the match
expression to match the enum’s variants and return the inner value or a suitable default.
let match_value = match returns_some() {
Some(val) => val,
None => "My default value".to_owned(),
};
println!("match {{...}}: {:?}", match_value);
if let
expressions
You can enter a block conditionally based off an enum’s variant. It’s easier to explain with an example:
if let Some(val) = returns_some() {
println!("if let : {:?}", val);
}
If the Option
returned by returns_some()
is Some()
then its inner value will be bound to the identifier val
. It’s strange syntax to get used to, but it’s useful.
Automagic unwrapping with ?
Short circuiting, or returning early, is a common way of dealing with error cases. When you get an error or a None
, return right away and let the caller deal with it. Rust
embodies this concept into the ?
operator.
The code below shows a few new tricks.
use std::fs::read_to_string;
fn main() -> Result<(), std::io::Error> {
let html = render_markdown("./README.md")?;
println!("{}", html);
Ok(())
}
fn render_markdown(file: &str) -> Result<String, std::io::Error> {
let source = read_to_string(file)?;
Ok(markdown::to_html(&source))
}
- First, we’ve changed our
main()
to return aResult
by using-> Result<(), std::io::Error>
on line 3. Remember,()
is the unit type. It’s another way of saying nothing. You can read a return value like-> Result<(), ...>
as “Returns nothing, but may fail”. - Second, we’re using
std::fs::read_to_string()
on line 10. It takes a path and returns aResult<String, std::io::Error>
. That is, it returns either the contents of a file as aString
, or it returns an error of the typestd::io::Error
. - Third, We automagically unwrap the result into the variable
source
with the?
operator on line 10. If the result is an error, the?
returns the result back to the caller, in this casemain()
. - Fourth, We automatically unwrap the result from
render_markdown
inmain()
with another?
on line 4. Since there’s no caller abovemain()
, an error here will kill our program. - Fifth, We finish our
main()
withOk(())
because our return type isResult<(), ...>
. We don’t care about the value we return, but we have to returnOk()
regardless.
?
vs try!
You may see references to the try!
macro in some older posts. try!
was the precursor to ?
. While try!
is deprecated in favor of ?
, it’s still a great way to understand what’s happening. The implementation is here and below.
try!
is a macro and uses macro syntax which will look foreign at first. Macros are beyond the scope of this guide but you’re a smart cookie. I bet you can get the gist of what’s going on here:
macro_rules! r#try {
($expr:expr $(,)?) => {
match $expr {
$crate::result::Result::Ok(val) => val,
$crate::result::Result::Err(err) => {
return $crate::result::Result::Err($crate::convert::From::from(err));
}
}
};
}
The try!
macro takes an expression and uses that expression in a match
statement. If the expression
is Ok
it returns the inner value. If it’s an error, it returns early and converts the error into the returning Result
’s Error type. That last part is an important one. Let’s see what happens if we change our example to take our file path from an environment variable rather than a hardcoded string.
use std::fs::read_to_string;
fn main() -> Result<(), std::io::Error> {
let html = render_markdown()?;
println!("{}", html);
Ok(())
}
fn render_markdown() -> Result<String, std::io::Error> {
let file = std::env::var("MARKDOWN")?;
let source = read_to_string(file)?;
Ok(markdown::to_html(&source))
}
We’ve added one line to get the value of an environment variable named "MARKDOWN"
. That function will fail if no such variable exists. We use another question mark (?
) to short circuit but now we have a compilation error: `?` could not convert the error to std::io::Error
…
That error leaves us at another Rust WTF-juncture. A WTFuncture. A Rust-T-F. You understand Option
s and Result
s. They’re not that scary, but how the heck do you deal with all the different errors? Look out for the next post which will dive into errors.
Additional reading
- The Rust Book: ch 06 - Enums
- Rust by Example: Enums
- The Rust Reference: Enumerations
- Rust by Example: match
- Rust by Example: if let
- Rust docs: Result
- Rust docs: Option
Wrap-up
Option
s and Result
s are everywhere in Rust. You should try thinking in terms of them right away. Enums themselves are everywhere, for that matter. You will often find its better to return or accept values in terms of enums instead of magic values like strings or numbers and booleans that mean more than just true
or false
.
This section was all lead-in to the next part of the guide where we go over how to deal with the Err
side of the Result
.
As always, you can reach me personally on twitter at @jsoverson, the Candle team at @candle_corp, and our Discord channel.
Written By
