Idiomatic Rust

Practical Rust API Design

A good metric for ergonomic systems design is how much of a program you have to keep in your head at once to know what’s going on. It’s empowering if you can understand a function by its type signature and get immediate feedback on whether you used it correctly. Other times, it feels like you’re a code archaeologist: Was this value validated before? Is it safe to retry this call? Can this panic? Bad APIs make local reasoning hard.

“Local reasoning” here means being able to understand a piece of code from a limited amount of surrounding context and the contracts of the APIs it uses. The main point is that you can rely on those contracts without additional knowledge of the implementation.

What makes Rust feel different from other languages is the ability to encode invariants in the type system and do so at zero cost. The combination of both properties is rare.

Once you notice this “local reasoning principle”, you’ll see it everywhere in Rust’s standard library: through the use of Result and Option, borrows marked with &, enums to represent a closed set of possibilities, or explicit unsafe blocks. This information is always visible in every function signature.

A simple way to apply this mindset yourself is to check if your function signatures communicate as much information as possible to the caller. Maybe show the signature to a friend or colleague and ask them to explain what it does. It can be eye-opening.

Let’s look at a few examples.

Put Relationships in the Signature

Consider this function signature:

fn first_line(text: &str) -> Option<&str>

Before even looking at the implementation, we already know that the function takes a borrowed input and may have no output. (Or, rather, it may return None.) Actually, if you know Rust’s lifetime elision rules, you know that the real signature is:

fn first_line<'text>(text: &'text str) -> Option<&'text str>

The input is tied to the output. That implies that the caller can’t keep using the returned string after the borrow of text has ended. And by extension, the function can’t return a temporary string either. The function is not making any long-lived allocations.

That’s a lot of useful information for just one function header!

All of that is great, but equally important, there are a few “implicit” assumptions that the signature does not guarantee. Take the function name: it suggests that the function returns “the first line of something.” However, the type system does not enforce that. It could return the last line for all we know, and the signature would be identical.

The function header also won’t tell us whether anything is logged, how it performs, or whether it panics.

Rust is often described as an explicit language. Yet it also uses type inference, lifetime elision, automatic borrowing of method receivers, and implicit coercions. That’s because writing everything out would make many programs harder to read. What you should make explicit depends on your context.

A good rule of thumb when designing an API is to look for relationships that callers would otherwise have to keep in their heads. Does your function really take any &str, or does it expect a string that has been validated in some way? For example, is an empty string valid input? What does None mean in the return value? Should it be treated as an error, or is it a valid case? Should it be a Result instead? Good function signatures tell a story about the relationships between inputs and outputs.

Keep Consequential Choices Visible

Consider these two calls, where text is a String:

inspect(&text);
consume(text);

The first call borrows text while the second one moves it.

Now let’s look at the context of the borrowed call:

fn inspect(text: &str) {
    println!("{text}");
}

let text = String::from("hello");
inspect(&text);

The compiler applies a deref coercion automatically. I think that’s a good compromise: the ownership decision is still visible, but the compiler handles the bookkeeping.

Some people argue that we could go one step further. Why not automatically borrow an owned argument in an ordinary function call? We could then write inspect(text) instead of inspect(&text), which seems convenient. But, as always, there’s a cost to convenience. Saving the & would remove information readers currently get from the expression itself: that the value does not move.[1]

Convenience does not always mean better ergonomics. This gives us a way to judge our own conveniences, too. Implementing Deref for a wrapper makes its target’s methods available implicitly. That’s convenient. But it’s a slippery slope. It can lead to leaky abstractions, where a wrapper type is treated as if it were the underlying type, but is not quite the same.

Suppose a UserId stores a String. You might consider implementing Deref<Target = str> for it, so that callers can use it as if it were a &str. Now you expose the whole string interface through Deref. You implicitly allow callers to treat the identifier as text, sidestepping all type invariants. Instead, an explicit as_str() leaves a visible point where the identifier is treated as text. It also lets your UserId newtype have an API of its own. Interaction with user IDs becomes more deliberate.

Use Deref only when the wrapper transparently behaves like its target and dereferencing is cheap and unsurprising. The standard library agrees.

Give Names a Purpose

Take a look at this function signature:

fn visit<F: FnMut(&str)>(callback: F)

Here, we introduce a name, F, give it a bound of FnMut(&str), and then use it exactly once for callback. I think that signature would be clearer if we put the requirement right next to where it’s used:

fn visit(callback: impl FnMut(&str))

Now you can read the signature from left to right. This saves you from jumping back and forth just to figure out what F means. One less thing to keep in your head while reading.[2]

Now, I’m not saying that version two is always better. For example, it can be helpful to keep generics separate from the rest of the signature when the generic type is used in multiple places:

fn choose<T>(first: T, second: T, take_first: bool) -> T {
    if take_first { first } else { second }
}

Here, T tells us that both arguments as well as the return value have the same type. The same thinking applies to return types:

fn nonempty(lines: &[String]) -> impl Iterator<Item = &str> {
    lines.iter().map(String::as_str).filter(|s| !s.is_empty())
}

impl Iterator<Item = &str> tells the caller what they can do with the result, namely iterate over borrowed strings. (Besides, if you tried writing out the concrete return type of that function, it would be unnecessarily long and complicated.)

Ask yourself: does naming this type help the caller understand something?

Ownership Beyond Memory

We usually learn about ownership in the context of memory. But the same rules apply in other situations.

Take file descriptors, for example. On Unix, a raw file descriptor is just an integer. That integer doesn’t tell you whether the descriptor is still open, or who’s responsible for closing it. Worse, once it’s closed, the operating system can reuse the number for something else.

Consider these two signatures, using types from std::os::fd:

fn inspect(fd: RawFd) -> std::io::Result<()>
fn inspect(fd: BorrowedFd<'_>) -> std::io::Result<()>

The first signature only gives us an integer. RawFd is literally just an alias for c_int. But that descriptor might already be closed. Those “time-of-check to time-of-use” bugs are a common pitfall of safe Rust.

The second guarantees that the descriptor remains open for the duration of the borrow.[3] The caller keeps its owner alive, and Rust checks that relationship when we borrow from a File:

use std::fs::File;
use std::os::fd::AsFd;

let file = File::open("notes.txt")?;
inspect(file.as_fd())?;

Now the compiler can help! You can’t drop file and then keep using the descriptor borrowed from it in safe Rust. You no longer need to search through the code to check whether someone closed it earlier.

But what if our function should really take ownership of the file descriptor? Use OwnedFd instead. When the OwnedFd is dropped, the descriptor is closed automatically, so the caller doesn’t have to remember to do it.

In a sense, memory and file descriptors share a similar set of types with different guarantees:

MemoryFile descriptorsUse Case
Box<T>OwnedFd“I want to own this resource and close it when I’m done.”
&TBorrowedFd<'a>“I want to borrow this resource for a limited time.”
Raw pointerRawFd“I want to use this resource, but I don’t know who owns it or how long it will live.”

Lock guards are another example.

{
    let _guard = lock.lock();
    // do cool things with lock
} 
// We no longer have access to the lock here, because `_guard` was dropped. 

If you can access the data, you hold the lock. You don’t have to “trace the program back” to an earlier lock() call and check every path for an unlock. In C, that’s very much the case and easy to get wrong.

Of course, these types only guarantee what they encode. A BorrowedFd keeps track of one borrow, but it doesn’t guarantee exclusivity over the underlying resource. That means another process might still be writing to the same file.

But in general, you can stop relying on callers to remember the provenance of a resource. That’s a much stronger guarantee than simply saying “keep this open until you’re done” in your API documentation.

Raw handles are still necessary at a low-level boundary, but you don’t have to pass them through your entire application.

The lesson is that you can encapsulate ownership information in your own types and provide a safe wrapper around an unsafe API.

Explain Who Is Responsible

Sometimes things are truly outside of Rust’s control. In that case, the safety responsibility shifts to the user. We use unsafe APIs to make that inversion of responsibility explicit.

In Rust, there are two different responsibilities, which share the same keyword:

  • An unsafe fn says: “Before you call me, you MUST establish these conditions. This is your responsibility.”
  • An unsafe block means you’re responsible for satisfying the conditions of the unsafe operations inside the block.[4]

The difference is that an unsafe fn sets safety conditions that its caller must meet, while an unsafe block marks where the person writing the code claims that each unsafe operation’s safety conditions have been met.

In both cases, it is good practice to add safety comments to make readers aware of these conditions. Here’s how that could look in practice:

/// # Safety
/// `index` must be less than `values.len()`.
unsafe fn element_unchecked(values: &[u8], index: usize) -> u8 {
    // SAFETY: The caller guarantees that `index` is in bounds.
    unsafe { *values.get_unchecked(index) }
}

In general, you should use get(index) instead to handle the None case, but this example illustrates how to document safety obligations.

Since the type system can’t check the safety conditions, it’s your obligation to keep the documentation up to date. Suppose someone adds another unsafe operation to this function later. Does knowing that index is in bounds make that operation safe, too? Maybe. You have to check and potentially update your docs.

Quick tip: when you write a safety comment, explain why the operation is safe. “This is safe” doesn’t help the next person, but “The caller guarantees that the index is in bounds” gives them something they can check.

Another tip: in Rust 2024, unsafe operations inside unsafe functions warn by default unless you put them in an explicit unsafe block. You can enforce that with #![deny(unsafe_op_in_unsafe_fn)].

Don’t Make Callers Do Your Work

You’ve probably heard the advice to panic for programmer errors and return Result for recoverable failures. That’s reasonable, but who decides what counts as a programmer error?

You do, when you design the API.[5]

Consider the difference between indexing and get:

let item = items[index];
let item = items.get(index);

If you use indexing, you need to know that the index is valid to avoid a panic. With get, you can try the lookup and handle None if it fails. Remember that both are safe Rust: an invalid index doesn’t cause undefined behavior in either case.

Now suppose the index comes from user input. Is an out-of-bounds index really a bug in your program? Or is it something you should expect and handle?

One escape hatch is to make every caller check the index before calling your function. A strict precondition might make your work simpler, but think about your users. Before you document another thing the caller “must” do, ask whether your API could handle it instead. For example, you could return an Option and let callers decide what to do next.

That doesn’t mean you should avoid indexing altogether. If an index is indeed valid by construction, indexing can express that assumption directly. A panic then means there’s a bug in your API. It’s probably fine to panic in that case, instead of introducing undefined behavior.

And often it’s possible to sidestep these issues entirely. For example, if you need to visit each element of a collection, use an iterator. This way, you don’t have to worry about indices at all.

Decide What You Want to Promise

Another mental model for building great APIs is to think about what you want to guarantee to your users. Remember Hyrum’s Law:

With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.

With that in mind, think about what happens when you try to change your API.

For example, users can match on every variant of your public enum. They can’t forget a case, because the compiler will warn them about it. That’s local reasoning at work, which is great. But on the flip side, it also means that you can’t add a variant without breaking their code. You’ve broken a guarantee they depended on.

To prevent that, mark your enum as #[non_exhaustive] so you can add variants later:

#[non_exhaustive]
pub enum ServiceError {
    Unavailable,
    Rejected,
}

In that case, users have to include a fallback when matching the enum.[6] You’ve pushed the responsibility to the call site, which is likely the better place to decide what to do with an unexpected variant. Exhaustive matching keeps working inside your own crate.

Should you add #[non_exhaustive] to every public enum just in case? No. If the set of possibilities really is closed, exhaustive matching gives users a useful guarantee. Don’t take it away without a reason. Examples of closed sets include days of the week, months of the year, or the suits of a deck of cards:

pub enum Suit {
    Hearts,
    Diamonds,
    Clubs,
    Spades,
}

If you make this enum non-exhaustive, users can’t use your crate to implement a card game without having to handle an impossible case.

The awkward middle ground is a set that looks closed but really isn’t. HTTP status codes are a good example: mapping all the standard codes doesn’t mean you’re safe from a vendor inventing their own codes. This caused a real problem in http-types: a user reported that constructing a response with Cloudflare’s custom status codes panicked because the library’s StatusCode enum couldn’t represent them.

#[non_exhaustive] doesn’t solve that problem by itself. It lets you add additional variants in the future, but it doesn’t allow users to represent unknown codes today. How about we add Unregistered(u16)?

#[non_exhaustive]
pub enum Status {
    Ok,
    NotFound,
    // Other known status codes we can't name yet...
    Unregistered(u16),
}

Now users can handle codes which don’t have a name yet:

fn is_early_hints(status: Status) -> bool {
    match status {
        Status::Unregistered(103) => true,
        _ => false,
    }
}

But that introduces another compatibility trap. Suppose a later release adds an EarlyHints variant and starts returning it for code 103. The function now returns false for the same HTTP status code. The code still compiles, but its behavior has changed. I recommend reading “Pattern Matching and Backwards Compatibility” by Sean McArthur, the author of the http crate, about how even an enum with a catch-all variant can make promises you didn’t intend.

The escape hatch for the http crate was to make StatusCode an opaque struct with associated constants for the known codes like StatusCode::OK. Users can then construct values from numeric codes, even when the library doesn’t have a name for them:

use http::StatusCode;

let status = StatusCode::from_u16(599).unwrap();
assert_eq!(status.as_u16(), 599);

They can still match on familiar codes, but they have to include a fallback. And they can handle an unnamed code by inspecting its numeric value:

use http::StatusCode;

fn describe(status: StatusCode) -> &'static str {
    match status {
        StatusCode::OK => "success",
        code if code.as_u16() == 103 => "early hints",
        _ => "some other status",
    }
}

That’s pretty clever, because adding a new constant like StatusCode::EARLY_HINTS assigns a name to a value without changing the underlying representation, and numeric checks continue to work.

So before making something public, ask yourself: am I willing to uphold this guarantee forever? This applies to your entire public API, including enums and public fields inside structs.

Changing things later can break user code. From their perspective, it was part of the API all along.

Try It on Your Own APIs

I suggest you put that advice into practice. Pick a function in your codebase and look at it from the caller’s perspective. What do you have to know to use it correctly? Can you get that information from the signature, or do you have to read the implementation first?

Look for instructions in the documentation that the compiler could help enforce. Instead of writing “keep this resource alive”, maybe you can return a borrowed type. “Only pass validated text” might become a newtype with some validation in the constructor.

You don’t have to follow every single suggestion in this article, either. The goal is to make your API easier to understand and harder to misuse.

Where to go from here

Want a second pair of eyes on your Rust APIs? I can help you put these patterns to work in your codebase.

  • For your team. Training, code review, and architecture support to ship Rust with confidence.
  • For yourself. 1-on-1 mentorship for Rust design, architecture, and code review on real projects.
  1. RFC 241: Deref coercions discusses why automatically borrowing arguments would make local reasoning harder. ↩

  2. RFC 1951: Expand impl Trait explains ergonomics in terms of how much you have to keep in your head, and discusses who chooses the concrete type. ↩

  3. RFC 3128: I/O safety explains the analogy between raw handles and raw pointers, and introduces owned and borrowed handle types. ↩

  4. RFC 2585: Unsafe blocks in unsafe functions separates defining safety obligations from satisfying them. The Rust 2024 edition guide covers the lint’s current default. ↩

  5. RFC 236: Error conventions recommends expressing contracts through types where possible, and using Result or Option when a stricter contract is hard to justify. The discussion of task failure predates Rust 1.0; the advice about API contracts is the relevant part here. ↩

  6. RFC 2008: Non-exhaustive enums and structs discusses exhaustive matching and the freedom to add new variants. ↩