Diving deeper into Rust’s Option & Result: Solving the Nesting Problem

Murray Todd Williams
12 min readOct 21, 2024

--

Ferris the Crab hides inside many embedded boxes

In the last article, we talked about Rust’s Option and Result type classes. We showed how idiomatically best to act on their contents with pattern matching as well as using the .map operator to transform their contents.

In this second article of our three-part series, we’re going to introduce a fundamental Functional Programming concept that, on its surface, may not seem that groundbreaking. But you’ll soon see how powerful it is and how crucial it is at untangling code that could otherwise get really unwieldy.

Rust series on Option and Result type classes

3 stories

We previously were working with an example where we were reading a temperature sensor’s measurement and, if we were successful at getting a reading, sending the results over the network to some “edge server” that collects the sensor telemetry. We started with the sensor reading being stored as an Option<Reading> and modified the example to use the more typical Result<Reading,&str> where we could represent a simple error message with a string description.

The Option variation of our example looked like this:

fn take_temp(sensor: &TempSensor) -> bool {
match sensor.get_reading() {
Some(measurement) => send_telemetry(measurement),
None => false,
}
}

And the Result variation looked like this:

fn take_temp(sensor: &TempSensor) -> Result<(),&str> {
match sensor.get_reading() {
Ok(measurement) => if send_telemetry(measurement) {
Ok(())
} else {
Err("Could not deliver reading telemetry")
},
Err(e) => Err(e),
}
}

We’re going to add one more layer to our puzzle by considering whether we were able to locate and configure the temperature sensor on our I²C bus. For the simple Option variation, let’s see what happens when we just change our sensor: &TempSensor parameter type to &Option<TempSensor>.

fn take_temp(sensor: &Option<TempSensor>) -> bool {
match sensor {
Some(s) => {
match (*s).get_reading() {
Some(measurement) => {
send_telemetry(measurement)
},
None => false,
}
},
None => false,
}
}

This got complex really quickly! I was even surprised that I had to explicitly dereference the sensor in the first pattern match. Let’s see what happens when we try to graduate this to Result-land…

fn take_temp(sensor: &Result<TempSensor,&'static str>) -> Result<(),&'static str> {
match sensor {
Ok(s) => match (*s).get_reading() {
Ok(measurement) => {
let success = send_telemetry(measurement);
if success {
Ok(())
} else {
Err("Could not deliver telemetry" )
}
}
Err(e) => Err(e),
},
Err(e) => Err(*e)
}
}

Quick side-note: let’s clean up the de-referencing!

On both these Option and Result examples, it’s been a pain dealing with de-referencing the wrapped Ok() and Err() values. Both type classes have a nice .as_ref() method that will make life much more pleasant. If you have an object with type &Option<T> , calling .as_ref() will transform it conveniently to Option<&T>, and similarly it can convert a &Result<T,E> into a Result<&T,&E>. We will call it on the outer-most pattern matched value:

fn take_temp(sensor: &Result<TempSensor,&'static str>) -> Result<(),&'static str> {
match sensor.as_ref() {
Ok(s) => match s.get_reading() {
Ok(measurement) => {
let success = send_telemetry(measurement);
if success {
Ok(())
} else {
Err("Could not deliver telemetry" )
}
}
Err(e) => Err(e),
},
Err(e) => Err(*e)
}
}

It’s not much of a difference, but I find it more readable, and I personally think it’s easier to think of an Option<&T> than an &Option<T> for some reason. Note that I still had to manually de-reference the outermost error passing, because my IDE hinting was showing the value as Err(e: &&str) => Err(e). (If you come to Rust from C/C++, this is probably easy to understand. If you come from a garbage-collecting language like Java or Scala, these ampersands and asterisks can get really confusing!)

By the way, one more side note for beginners: it may seem confusing that I’m explicitly passing along &'static str values. Since this example comes from an Embedded Rust example, I’m assuming we’re living in “no-std” land and don’t have the luxury of the whole “alloc” framework where dynamically allocated strings live. If you wanted to take this into a Rust world with the std library, you might be passing real string objects and using String::from("Could not deliver telemetry") instead.

Happy & Unhappy Paths

It’s worthwhile to think about the problem as a series of “happy” and “unhappy” paths. The happy paths are where we’re writing code that is thinking about our actual business logic. It’s the stuff where we’re thinking about solving our problem at hand or doing what our application was meant to do.

Coding for both happy and unhappy paths

The unhappy paths represent all of those “what if” scenarios that we have to consider and handle, without which we’re likely to have our application crash (panic) or behave in an otherwise buggy manner. In the above code, only Unhappy Path 3 represents any meaningful logic where we are converting the false return value of the send_telemetry() method a more meaningful error message, i.e. converting a boolean response into the Result<(),&str> type the function needs to return. The other paths are essential but basically meaningless boilerplate. What you’ll see at the end of this whole journey will be that we can write code that focuses on the happy paths while being provably robust with regard to all those unhappy paths.

Map to the Rescue?

In the last article, I presented an option of using the .map() function to apply business logic to the “happy path”. For the simple “Option” flavor of our application, we had the following:

fn get_reading3(sensor: &TempSensor) -> bool {
sensor.get_reading().map(send_telemetry).is_some_and(identity)
}

Here you saw that out logic was able to focus on the Happy Path with calling the .get_reading and send_telemetry functions. The potential None value from a potential reading failure automatically merged with the potential false value from a potential telemetry failure. Can we use .map to help us now? Let’s trying writing this with our additional sensor allocation failure case:

fn take_temp(sensor: &Option<TempSensor>) -> bool {
sensor.as_ref()
.map(|s| s.get_reading() // returns Option<Reading>
.map(send_telemetry) // now Option<Option<bool>>
)
}

Unfortunately, we were wanting a boolean return value and instead we’re getting an Option<Option<bool>>. Let’s try the same thing in the Result paradigm:

fn take_temp(sensor: &Result<TempSensor,&'static str>) -> Result<(),&'static str> {
sensor.as_ref()
.map(|s| s.get_reading()
.map(send_telemetry)
.map(|success| if success { Ok(()) } else { Err("Could not send telemetry")})
)
}

If I try to compile this, the compiler tells me we’re returning a Result<Result<Result<(),&str>,&str,&&_> instead of the desired Result<(),&'static str> type. It would be nice if the .map function could be used here, but we’re getting a nesting challenge.

Thinking about Type Classes

Let’s pause and look at this conceptually. I’ve used the term “type class” here and there without giving it a proper definition, and I distinctly remember feeling confused in my early Scala days when people would use the term.

As we see with both Option<T> and Result<T,E>, we are completely free to put any sort of value or object inside. I like to think of it as just a box or container that provides some special representation.

The .map operation is how we apply a function to the contents of the boxes. However, there’s a problem that we’ve just found ourselves running into where you try to apply .map to a function that returns a value in it’s own “box”. We get a nesting problem that starts to resemble the classic Russian Dolls.

There is a special version of map that deals with this nesting problem. In other languages (notably Scala) this would be called “flatMap”. For whatever annoying reason, the Rust language community decided to use the term .and_then() . In fact, in my IDE when I start typing the function’s name, the quick tool-tip says .and_then() (alias flatmap).

Here’s what flat-map (i.e. .and_then()) does: if you have a type class like Option or Result that acts as a “box” for some value of type T and you have a function that takes a value of type T and creates potentially another box—where this box within a box would create the nesting problem we’ve been seeing in the above examples—it flattens things out so you just get a single-layer result.

So if I have a value foo: Option<T> and a function fn bar(T) -> Option<S>, then I can call foo.and_then(bar) and the result will be of type Option<S>. If either the original foo or the result of the bar() function call would result in a value of None, the whole thing flattens to None. If both foo and the function bar() result in a Some() result, then the whole thing results to a Some(S) result rather than the Some(Some(S)) result that .map would have resulted in.

And we can see this works nicely:

fn take_temp(sensor: &Option<TempSensor>) -> bool {
sensor.as_ref()
.and_then(|s| s.get_reading().map(send_telemetry))
.is_some_and(identity)
}

By shifting from .map() into .and_then(), we were able to work with an Option<Reading> instead of an Option<Option<Reading>>. I admit there’s still some weirdness around the last line. This comes from the fact that I decided to create this example where the send_telemetry() function was using a boolean value to represent success or failure, so we are needing to use the .is_some_and(identity) to flatten the nested happy paths (some reading exists + telemetry delivery was successful) into a single boolean. If send_telemetry() had returned some sort of Option instead, we could have called .and_then() again.

And in fact, this is how things look when we elevate this example to the world of Result:

fn take_temp(sensor: &Result<TempSensor,&'static str>) -> Result<(),&'static str> {
sensor.as_ref().map_err(|e| *e)
.and_then(|s| s.get_reading())
.map(send_telemetry)
.and_then(|s| if s { Ok(()) } else { Err("Could not send telemetry") })
}

There’s still a little complexity in this example—notably the little .map_err call I had to put in there. The .as_ref() call transforms a &Result<T,E> into a Result<&T,&E> which means that my &Result<TempSensor,&str> got converted into a funky Result<&TempSensor,&&str> and I had to de-reference the error to get it back into a proper &str reference. Note that .map_err behaves just like we’ve seen with .map, applying a function only to the error branch while leaving the success branch (the happy path) alone. You’ll find this a very useful tool in cases where you need to apply some transformations to an error type in order to conform your logic to an different expected return type.

Notice also the fact that we have both .map and .and_then intermingled here in this example. This is very typical where you have a series of transformation steps, some of which “box” their results into Option or Result and others which just do straight transformations with no boxing. (That is, transformations where there’s no expected edge-cases that we have to handle with None or Err.)

One quick last note that you need to remember: the .and_then (flat map) operation only works with boxes of the same type. You can’t intermingle functions that create Option values with ones that create Result values. If you do, you’ll need to “convert” one to the other—for example converting Some(value) into Ok(value) and converting None into Err(some error message). By the way, Option has methods .ok_or and .ok_or_else to support this very conversion, while Result has the method .ok() to convert the other way into an Option.

Quick Detour: Collections

There’s another interesting quirk with the way Rust handles map and “flat map”. In Scala, there are more examples of these magic type class boxes. In addition to Option and Result (which is called “Either” in Scala), we can do the same things with all of the collection classes.

It turns out that Rust has similar (albeit quirky) capabilities. Let’s say I have a vector my_list: Vec<i32> of integers and I want to apply a transformation such as squaring to each value. I can do that like this:

let my_list = vec!(1, 2, 3, 4, 5);
let squares: Vec<i32> = my_list.into_iter().map(|x| x * x).collect();

If I print out the result of squares, I’ll get [1, 4, 9, 16, 15], as one would expect. So we see that anything that can be transformed into an iterator gets the useful .map operation.

Now, what if I have a transformation that doesn’t just transform an integer into another integer (e.g. squaring) but it transforms an integer into another vector? For a simple example, let’s say we want to just repeat each value twice. We could write that as a lambda function |x| vec!(x,x).

If Rust’s iterators have .map(fn) just like Option and Result, do they also have .and_then? The answer is a little quirky: this time we have the properly named .flat_map(fn).

let doubles: Vec<i32> = squares.into_iter().flat_map(|x| vec!(x,x)).collect();

If I print out doubles, I’ll see [1, 1, 4, 4, 9, 9, 16, 16, 25, 25]! So instead of dealing with a vector of vectors—that same classic nesting problem—it all gets flattened out into a single long vector.

Treating Option and Result as Collections

There’s still more interesting stuff down this particular rabbit hole. It turns out that Option and Result can both be treated as a sort of nested collection. If you wanted, you could call .into_iter on one, and the body of your iterator would only run if the Option value was Some(T) or the Result value was an Ok(T).

Using the previous simple example, if (for some reason) I wanted to filter out odd numbers from my doubles vector, I could use create a function that converted even numbers into Some(x) and odd numbers into None. Rather than using .map to get a Vec<Option<i32>> , I could call .flat_map to compress the vector into one with only the even values:

let evens: Vec<i32> = doubles
.into_iter()
.flat_map(|x| if x % 2 == 0 { Some(x) } else { None })
.collect();

If you print out the evens value, you’ll get [4, 4, 16, 16]. The example is obviously a little contrived—it would be better to use .into_iter().filter(predicate) instead. But it’s not unusual in practice to find yourself with a collection of Option values where you’re only interested in working with the non-None elements.

A better example is if you have a collection of sensors represented by Result<Sensor,E> and you want to reduce it to a collection of only those that were configured properly in an Ok<Sensor> state. We can represent that with the following simple code fragment:

let sensor1: Result<&str,&str> = Ok("MCP9808");
let sensor2: Result<&str,&str> = Ok("TMP36");
let sensor3: Result<&str,&str> = Err("Bad Sensor");
let good_sensors: Vec<&str> = [sensor1,sensor2,sensor3]
.into_iter().flatten().collect();
println!("{:?}", good_sensors);

If you run it, you’ll see the expected ["MCP9808","TMP36"]. Two things to note here:

  1. We already had a series of nested collections, so rather than doing some transformation with .flat_map, we’re just calling .flatten to de-nest things.
  2. You’ve seen me provide the explicit Vec<T> types in the last few examples. That’s because .collect is actually too general for the compiler to know exactly what type to collect the values into, so it needs us to provide the target type. We can either do it in the variable definition, or we can use what’s called the turbofish like this:
let good_sensors = [sensor1,sensor2,sensor3]
.into_iter().flatten().collect::<Vec<_>>();

As a final snippet of code example, here’s a very realistic scenario where we might want to define a function that takes a slice of many Result<TempSensor,&str> sensors, calls .get_reading on each one, and finally just returns a vector of the successful readings.

fn take_readings(sensors: &[Result<TempSensor, &'static str>]) -> Vec<Reading> {
sensors
.iter()
.flatten()
.flat_map(|s| s.get_reading())
.collect()
}

By the way, you should notice that this time I used .iter() instead of .into_iter(). The difference between these is that .into_iter() actually consumes the original collection’s items so the borrow checker won’t let you use them later on. In this particular use case, I know that calling .get_reading() on my sensor is just going to create a simple Reading struct. In fact, I’m likely to want to take multiple readings in the future, so .into_iter() would have gotten me in trouble!

That’s it for this article! We’ve gone over a lot of important concepts already, but we still haven’t gotten to the bottom of this rabbit hole. By now, you’re probably feeling more comfortable with .map and either .and_then or .flat_map, but you’re probably not feeling all that excited about writing code that reads so weirdly. For an FP enthusiast like me, reading these chains of transformers is perfectly natural, but most people would find it too hard to understand.

Before you give up and go back to writing all those nested match statements, I want to show you one more cool thing that will provide the best of both worlds! I’m also going to shed the funky &'static str error types and examine the idiomatic approach that real Rust developers take when it comes to proper error types. (It’s something that I found really weird as someone used to Java/Scala.) Read all about it in the final article of this mini-series!

--

--

Murray Todd Williams
Murray Todd Williams

Written by Murray Todd Williams

Life-long learner, foodie and wine enthusiast, living in Austin, Texas.

Responses (1)