Handling multiple errors in Rust iterator adapters
Recently I’ve spent a decent amount of time exploring different patterns for handling errors in iterator adapters based on some ideas I had while doing some advent of code questions where I was making extensive use of fallible operations.
In the end I’ve discovered two patterns that seem quite simple and helpful
Multiple Errors in a single adapter
The key idea here is to return Ok
from the adapter, enabling the use of the ?
operator.
Control on the error type returned is defined with the Result type specified in the specialisation of the generic iterator sink.
The sink could be for instance collect
, however in our example below we’re using sum::<Result<i32>>()?
with anyhow::Result
implicitly defining the error type as anyhow::Error
.
This gives us complete flexibility with the errors we can handle.
use anyhow::{Context, Result};
use itertools::Itertools;
let game_id_sum = lines
.map(|line| {
let (id, games) = line
.strip_prefix("Game ")
.context("has no 'Game ' prefix")?
.split_once(':')
.context("has no ':' separator")?;
Ok((
id.parse::<i32>().context("game id not valid int")?,
parse_games_desc(&games)?,
)) // Returning Ok(_) enables you to use the `?` operator in the closure
})
.map_ok(|(id, games)| { // Use itertools::Itertools::map_ok for subsiquent infalable operations
let result = (
id,
games.iter().fold(Game::default(), |max_seen, x| {
Game {
red: max_seen.red.max(x.red),
green: max_seen.green.max(x.green),
blue: max_seen.blue.max(x.blue),
}
}),
);
result
})
.map_ok(|(_id, game)| game.red * game.green * game.blue)
.sum::<Result<i32>>()?; // Error type is defined via the Result here
In a library context we would instead use sum::<Result<_,MyLibError>>()?
with MyLibError
defined using thiserror
handling the conversion from each of the errors that can happen
Multiple errors across different adapters
Building on the handling of errors in one adapter, we might subsequently want to handle additional errors in later adapters.
We want to make sure that we aren’t nesting the Result
s each time, as this makes the type structures hard to work with.
Ideally the “happy path” can continue with relatively simple access within the closures.
To this end, the pattern below making use of and_then
within
.map(|result| result.and_then(|ok_value| ok_value.fallible_method()) )
like operations
enables you to avoid the nesting of results you would get with .map_ok(|ok_value| ok_value.fallible_method()) )
In order to enable this we need to ensure that the error type is the same throughout the chain of adapters. Again anyhow makes this trivial in the application logic setting
use anyhow::{Context, Result};
use itertools::Itertools;
fn application_logic(input: &str) -> Result<u64> {
let sum = input
.lines()
.map(|l| l.parse::<u64>().context("failed top parse input data"))
.map_ok(|n| n as f32 / 3.0)
.map(|r| r.and_then(|f| 10u64.checked_div(f as u64).context("input numbers not suitable")))
.sum::<Result<u64>>()?;
Ok(sum)
}
Achieving the unified error type in a Library compatible fashion however is a little more complicated.
thiserror
helps us easily create Error variants for each of the error cases we need to handle.
So we just need to find the right way to convert
use itertools::Itertools;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum MyLibError {
#[error("failed to parse input data")]
BadInputError(#[from] std::num::ParseIntError),
#[error("input numbers not suitable")]
InvalidDivisor,
}
type Result<T> = std::result::Result<T, MyLibError>;
fn library_logic(input: &str) -> Result<u64> {
let sum = input
.lines()
.map(|l| l.parse::<u64>().map_err(|e| e.into())) // Alternative to implicit type conversion in `.context` which works for `thiserror`
.map_ok(|n| n as f32 / 3.0)
.map(|r| r.and_then(|f| 10u64.checked_div(f as u64).ok_or(MyLibError::InvalidDivisor)))
.sum::<Result<u64>>()?;
Ok(sum)
}
Future evolution
Some discussion on adding a shorthand for this into itertools have happened, but seem to have been dormant for a while:
It could look something along the lines of
.map_and_then(|f| 10u64.checked_div(f as u64).ok_or(MyLibError::InvalidDivisor))
Which helps to reduce the visual clutter somewhat