domain-specific error macros
— 2023-01-17

  1. what is ensure?
  2. pairing errors with macros
  3. new keywords
  4. stdlib
  5. conclusion

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:

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:

1

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.