domain-specific error macros
— 2023-01-17
When I write custom errors in a project, I also like to write a few small error
macros to accompany them. In my opinion this can make error handling just a
little nicer to use. In this post I briefly want to talk about domain-specific
error macros such as ensure!
, what they're useful for, and the io-ensure
prototype crate I've written which I'm propose for inclusion in the stdlib next
time I get the chance.
What is ensure?
If you use Rust you're probably familiar with
assert!
: it checks a
condition, and if the condition doesn't match it panics. This often looks
something like this:
let condition = 1 == 1;
assert!(condition, "numbers should equal themselves");
This works well if you're writing tests, or validating invariants - but when
you're validating input at runtime, instead of panicking you usually probably
want to return errors instead. And the error equivalent to assert!
is
ensure!
:
fn validate_buffet(s: &str) -> io::Result<()> {
ensure!(s.starts_with("tuna"), "the first course on the cat menu should be tuna");
Ok(())
}
When discussing "ensure-style macros", these are typically the ones included:
format_err!
: construct a new instance of an error using a format stringbail!
: unconditionally create and return an errorensure!
: create and return an error if the condition doesn't matchensure_eq!
: create and return an error if two expressions don't matchensure_ne!
: create and return an error if two expressions match
bail
is kind of like panic
, and ensure
is kind of like assert
. You can't
really construct and pass around panics, so there is no panic-equivalent to
format_err
. Also for simplicity I'm omitting matches
variants, and errors
probably shouldn't be debug-only, so no debug
variants either.
Pairing errors with macros
I believe it's good practice that if you're defining your own errors, to also define your own error handling macros. Especially if it's intended to be extended by third parties, it can make it significantly nicer to work with.
The key here is that you can use ensure!
macros to encode domain-specific
logic with. Say you're writing an HTTP framework, you probably want all of your
errors to include an HTTP status code. While if we're dealing with IO, we
probably want to map back the underlying OS reason:
// Example using `http_types::ensure!`
ensure!(user.may_access(resource), StatusCode::403, "user is not authorized to access {resource}");
// Example using `io-ensure`
ensure_eq!(a, b, ErrorKind::Interrupted, "we are testing the values {} and {} are equal", a, b);
new keywords
Work is happening in Rust on creating new language features to reason about
errors. areweyeetyet provides an overview of this,
but the tldr is that we want to have a way to define "fallible functions" from
which you can "throw" errors. Also: no more Ok(())
at the end of every
function.
Having access to a throw
(or yeet
) keyword means we'll have a canonical way
of returning an error from a function. ?
to re-throw errors, throw
to throw
new ones 1. But the bail
macro does something very similar
already, and has the downside of having to be defined for every error type:
I'm using "throw" in the Swift sense of the keyword: "checked exceptions". Not the JS/Java/etc "unchecked exceptions" throwing; I think we all like our Rust errors to be typed.
// current
return Err(io::Error::new(e.kind(), format!("{path}: {e}")).into());
// using `format_err!`
return Err(io::format_err!(e.kind(), "{path}:{e}").into());
// using `bail!`
io::bail!(e.kind(), "{path}:{e}"));
// if we had `throw` in the language (feature composition!)
throw io::format_err!(e.kind(), "{path}:{e}");
What I'm thinking here is that it makes sense to include bail
if you're
writing your own libraries and applications. But probably less for the stdlib,
since whatever goes in there will need to remain stable forever, and having
bail
macros + throw
keywords is probably a bit too much.
Stdlib
So I've written a quick prototype showing: "What if std::io
had domain-specific
macros" and published it to crates.io:
io-ensure
. Is it
great? I'm not sure. I think it's a decent quality of life improvement, and
perhaps it might make sense to include. Just like assert
can sometimes make it
easier to work with panics, ensure
might sometimes make it easier to work with
errors. Here's some random examples of IO error uses I translated from the
rust-lang/rust
repo:
This example omits an extra inline call to format
, removing some of the nesting:
// before
fs::create_dir_all(parent).map_err(|e| {
io::Error::new(
e.kind(),
format!("IO error creating MIR dump directory: {parent:?}; {e}"),
)
})?;
// with io macros
fs::create_dir_all(parent).map_err(|e| {
io::format_err!(e.kind(), "IO error creating MIR dump directory: {parent:?}; {e}");
})?;
rust/compiler/rustc_middle/src/mir/pretty.rs ←
If librustdoc
also had its own error macros, we could imagine we could compose
it with IO error macros like so, saving us from some nesting and branching:
// current
if nb_errors > 0 {
Err(Error::new(io::Error::new(io::ErrorKind::Other, "I/O error"), ""))
} else {
Ok(())
}
// with `librustdoc::ensure!` + `io::format_err!`
ensure!(nb_errors > 0, io::format_err!(io::ErrorKind::Other, "I/O error"));
Ok(())
rust/src/librustdoc/html/render/context.rs ←
This call to format
with the referencing and subslicing looks rather spicy.
I'm not super sure what's going on there, but presumably we should be able to
avoid it already? Either way, that's still an extra inline format
call gone:
// current
Err(io::Error::new(
io::ErrorKind::Uncategorized,
&format!("failed to lookup address information: {detail}")[..],
))
// with `io::ensure!`
Err(io::format_err!(
io::ErrorKind::Uncategorized,
"failed to lookup address information: {detail}"
))
rust/library/std/src/sys/unix/net.rs ←
Conclusion
In this post we've talked about domain-specific error macros, what they are,
what they're used for, and shown some examples of how they could be used for
io::Error
construction in the stdlib.
Overall it's just a little convenience pattern which makes working with errors
nicer. Just like assert!
is a small quality of life improvement which makes
panics nicer to use; ensure!
is a small quality of life improvement which
can make errors nicer to use.