Libraries / UX Guidelines



Abstractions Don't Visibly Nest (M-SIMPLE-ABSTRACTIONS)

To prevent cognitive load and a bad out of the box UX. 0.1

When designing your public types and primary API surface, avoid exposing nested or complex parametrized types to your users.

While powerful, type parameters introduce a cognitive load, even more so if the involved traits are crate-specific. Type parameters become infectious to user code holding on to these types in their fields, often come with complex trait hierarchies on their own, and might cause confusing error messages.

From the perspective of a user authoring Foo, where the other structs come from your crate:

struct Foo {
    service: Service // Great
    service: Service<Backend> // Acceptable
    service: Service<Backend<Store>> // Bad

    list: List<Rc<u32>> // Great, `List<T>` is simple container,
                        // other types user provided.

    matrix: Matrix4x4 // Great
    matrix: Matrix4x4<f32> // Still ok
    matrix: Matrix<f32, Const<4>, Const<4>, ArrayStorage<f32, 4, 4>> // ?!?
}

Visible type parameters should be avoided in service-like types (i.e., types mainly instantiated once per thread / application that are often passed as dependencies), in particular if the nestee originates from the same crate as the service.

Containers, smart-pointers and similar data structures obviously must expose a type parameter, e.g., List<T> above. Even then, care should be taken to limit the number and nesting of parameters.

To decide whether type parameter nesting should be avoided, consider these factors:

  • Will the type be named by your users?
    • Service-level types are always expected to be named (e.g., Library<T>),
    • Utility types, such as the many std::iter types like Chain, Cloned, Cycle, are not expected to be named.
  • Does the type primarily compose with non-user types?
  • Do the used type parameters have complex bounds?
  • Do the used type parameters affect inference in other types or functions?

The more of these factors apply, the bigger the cognitive burden.

As a rule of thumb, primary service API types should not nest on their own volition, and if they do, only 1 level deep. In other words, these APIs should not require users having to deal with an Foo<Bar<FooBar>>. However, if Foo<T> users want to bring their own A<B<C>> as T they should be free to do so.

Type Magic for Better UX?

The guideline above is written with 'bread-and-butter' types in mind you might create during normal development activity. Its intention is to reduce friction users encounter when working with your code.

However, when designing API patterns and ecosystems at large, there might be valid reasons to introduce intricate type magic to overall lower the cognitive friction involved, Bevy's ECS or Axum's request handlers come to mind.

The threshold where this pays off is high though. If there is any doubt about the utility of your creative use of generics, your users might be better off without them. 

Avoid Smart Pointers and Wrappers in APIs (M-AVOID-WRAPPERS)

To reduce cognitive load and improve API ergonomics. 1.0

As a specialization of M-ABSTRACTIONS-DONT-NEST, generic wrappers and smart pointers like Rc<T>, Arc<T>, Box<T>, or RefCell<T> should be avoided in public APIs.

From a user perspective these are mostly implementation details, and introduce infectious complexity that users have to resolve. In fact, these might even be impossible to resolve once multiple crates disagree about the required type of wrapper.

If wrappers are needed internally, they should be hidden behind a clean API that uses simple types like &T, &mut T, or T directly. Compare:

// Good: simple API
pub fn process_data(data: &Data) -> State { ... }
pub fn store_config(config: Config) -> Result<(), Error> { ... }

// Bad: Exposing implementation details
pub fn process_shared(data: Arc<Mutex<Shared>>) -> Box<Processed> { ... }
pub fn initialize(config: Rc<RefCell<Config>>) -> Arc<Server> { ... }

Smart pointers in APIs are acceptable when:

  • The smart pointer is fundamental to the API's purpose (e.g., a new container lib)

  • The smart pointer, based on benchmarks, significantly improves performance and the complexity is justified.



Prefer Types over Generics, Generics over Dyn Traits (M-DI-HIERARCHY)

To prevent patterns that don't compose, and design lock-in. 0.1

When asking for async dependencies, prefer concrete types over generics, and generics over dyn Trait.

It is easy to accidentally deviate from this pattern when porting code from languages like C# that heavily rely on interfaces. Consider you are porting a service called Database from C# to Rust and, inspired by the original IDatabase interface, you naively translate it into:

trait Database {
    async fn update_config(&self, file: PathBuf);
    async fn store_object(&self, id: Id, obj: Object);
    async fn load_object(&self, id: Id) -> Object;
}

impl Database for MyDatabase { ... }

// Intended to be used like this:
async fn start_service(b: Rc<dyn Database>) { ... }

Apart from not feeling idiomatic, this approach precludes other Rust constructs that conflict with object safety, can cause issues with asynchronous code, and exposes wrappers (compare M-AVOID-WRAPPERS).

Instead, when more than one implementation is needed, this design escalation ladder should be followed:

If the other implementation is only concerned with providing a sans-io implementation for testing, implement your type as an enum, following M-MOCKABLE-SYSCALLS instead.

If users are expected to provide custom implementations, you should introduce one or more traits, and implement them for your own types on top of your inherent functions. Each trait should be relatively narrow, e.g., StoreObject, LoadObject. If eventually a single trait is needed it should be made a subtrait, e.g., trait DataAccess: StoreObject + LoadObject {}.

Code working with these traits should ideally accept them as generic type parameters as long as their use does not contribute to significant nesting (compare M-ABSTRACTIONS-DONT-NEST).

// Good, generic does not have infectious impact, uses only most specific trait
async fn read_database(x: impl LoadObject) { ... }

// Acceptable, unless further nesting makes this excessive.
struct MyService<T: DataAccess> {
    db: T,
}

Once generics become a nesting problem, dyn Trait can be considered. Even in this case, visible wrapping should be avoided, and custom wrappers should be preferred.

#![allow(unused)]
fn main() {
use std::sync::Arc;
trait DataAccess {
    fn foo(&self);
}
// This allows you to expand or change `DynamicDataAccess` later. You can also
// implement `DataAccess` for `DynamicDataAccess` if needed, and use it with
// regular generic functions.
struct DynamicDataAccess(Arc<dyn DataAccess>);

impl DynamicDataAccess {
    fn new<T: DataAccess + 'static>(db: T) -> Self {
        Self(Arc::new(db))
    }
}

struct MyService {
    db: DynamicDataAccess,
}
}

The generic wrapper can also be combined with the enum approach from M-MOCKABLE-SYSCALLS:

enum DataAccess {
    MyDatabase(MyDatabase),
    Mock(mock::MockCtrl),
    Dynamic(DynamicDataAccess)
}

async fn read_database(x: &DataAccess) { ... }



Error are Canonical Structs (M-ERRORS-CANONICAL-STRUCTS)

To harmonize the behavior of error types, and provide a consistent error handling. 1.0

Errors should be a situation-specific struct that contain a Backtrace, a possible upstream error cause, and helper methods.

Simple crates usually expose a single error type Error, complex crates may expose multiple types, for example AccessError and ConfigurationError. Error types should provide helper methods for additional information that allows callers to handle the error.

A simple error might look like so:

#![allow(unused)]
fn main() {
use std::backtrace::Backtrace;
use std::fmt::Display;
use std::fmt::Formatter;
pub struct ConfigurationError {
    backtrace: Backtrace,
}

impl ConfigurationError {
    pub(crate) fn new() -> Self {
        Self { backtrace: Backtrace::capture() }
    }
}

// Impl Debug + Display
}

Where appropriate, error types should provide contextual error information, for example:

use std::backtrace::Backtrace;
#[derive(Debug)]
pub struct ConfigurationError {
   backtrace: Backtrace,
}
impl ConfigurationError {
    pub fn config_file(&self) -> &Path { }
}

If your API does mixed operations, or depends on various upstream libraries, store an ErrorKind. Error kinds, and more generally enum-based errors, should not be used to avoid creating separate public error types when there is otherwise no error overlap:

// Prefer this
fn download_iso() -> Result<(), DownloadError> {}
fn start_vm() -> Result<(), VmError> {}

// Over that
fn download_iso() -> Result<(), GlobalEverythingErrorEnum> {}
fn start_vm() -> Result<(), GlobalEverythingErrorEnum> {}

// However, not every function warrants a new error type. Errors
// should be general enough to be reused.
fn parse_json() -> Result<(), ParseError> {}
fn parse_toml() -> Result<(), ParseError> {}

If you do use an inner ErrorKind, that enum should not be exposed directly for future-proofing reasons, as otherwise you would expose your callers to all possible failure modes, even the ones you consider internal and unhandleable. Instead, expose various is_xxx() methods as shown below:

#![allow(unused)]
fn main() {
use std::backtrace::Backtrace;
use std::fmt::Display;
use std::fmt::Formatter;
#[derive(Debug)]
pub(crate) enum ErrorKind {
    Io(std::io::Error),
    Protocol
}

#[derive(Debug)]
pub struct HttpError {
    kind: ErrorKind,
    backtrace: Backtrace,
}

impl HttpError {
    pub fn is_io(&self) -> bool { matches!(self.kind, ErrorKind::Io(_)) }
    pub fn is_protocol(&self) -> bool { matches!(self.kind, ErrorKind::Protocol) }
}
}

Most upstream errors don't provide a backtrace. You should capture one when creating an Error instance, either via one of your Error::new() flavors, or when implementing From<UpstreamError> for Error {}.

Error structs must properly implement Display that renders as follows:

impl Display for MyError {
    // Print a summary sentence what happened.
    // Print `self.backtrace`.
    // Print any additional upstream 'cause' information you might have.
  fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
      todo!()
  }
}

Errors must also implement std::error::Error:

impl std::error::Error for MyError { }

Lastly, if you happen to emit lots of errors from your crate, consider creating a private bail!() helper macro to simplify error instantiation.

When You Get Backtraces

Backtraces are an invaluable debug tool in complex or async code, since errors might travel far through a callstack before being surfaced.

That said, they are a development tool, not a runtime diagnostic, and by default Backtrace::capture() will not capture backtraces, as they have a large overhead, e.g., 4μs per capture on the author's PC.

Instead, Rust evaluates a set of environment variables, such as RUST_BACKTRACE, and only walks the call frame when explicitly asked. Otherwise it captures an empty trace, at the cost of only a few CPU instructions. 

Complex Type Construction has Builders (M-INIT-BUILDER)

To future-proof type construction in complex scenarios. 0.3

Types that could support 4 or more arbitrary initialization permutations should provide builders. In other words, types with up to 2 optional initialization parameters can be constructed via inherent methods:

#![allow(unused)]
fn main() {
struct A;
struct B;
struct Foo;

// Supports 2 optional construction parameters, inherent methods ok.
impl Foo {
    pub fn new() -> Self { Self }
    pub fn with_a(a: A) -> Self { Self }
    pub fn with_b(b: B) -> Self { Self }
    pub fn with_a_b(a: A, b: B) -> Self { Self }
}
}

Beyond that, types should provide a builder:

struct A;
struct B;
struct C;
struct Foo;
struct FooBuilder;
impl Foo {
    pub fn new() -> Self { ... }
    pub fn builder() -> FooBuilder { ... }
}

impl FooBuilder {
    pub fn a(mut self, a: A) -> Self { ... }
    pub fn b(mut self, b: B) -> Self { ... }
    pub fn c(mut self, c: C) -> Self { ... }
    pub fn build(self) -> Foo { ... }
}

The proper name for a builder that builds Foo is FooBuilder. Its methods must be chainable, with the final method called .build(). The buildable struct must have a shortcut Foo::builder(), while the builder itself should not have a public FooBuilder::new(). Builder methods that set a value x are called x(), not set_x() or similar.

Builders and Required Parameters

Required parameters should be passed when creating the builder, not as setter methods. For builders with multiple required parameters, encapsulate them into a parameters struct and use the args: impl Into<Args> pattern to provide flexibility:

Note: A dedicated args struct is not required if the builder has no required parameters or only a single simple parameter. However, for backward compatibility and API evolution, it's preferable to use a dedicated struct for args even in simple cases, as it makes it easier to add new required parameters in the future without breaking existing code.

#[derive(Debug, Clone)]
pub struct FooArgs {
    pub logger: Logger,
    pub config: Config,
}

impl From<(Logger, Config)> for FooArgs { ... }
impl From<Logger> for FooArgs { ... } // In case we could use default Config instance

impl Foo {
    pub fn builder(args: impl Into<FooArgs>) -> FooBuilder { ... }
}

This pattern allows for convenient usage:

  • Foo::builder(logger) - when only the logger is needed
  • Foo::builder((logger, config)) - when both parameters are needed
  • Foo::builder(FooArgs { logger, config }) - explicit struct construction

Runtime-Specific Builders

For types that are runtime-specific or require runtime-specific configuration, provide dedicated builder creation methods that accept the appropriate runtime parameters:

#[cfg(feature="smol")]
#[derive(Debug, Clone)]
pub struct SmolArgs {
    pub clock: Clock,
    pub io_context: Context,
}

#[cfg(feature="tokio")]
#[derive(Debug, Clone)]
pub struct TokioArgs {
    pub clock: Clock,
}

impl Foo {
    #[cfg(feature="smol")]
    pub fn builder_smol(args: impl Into<SmolArgs>) -> FooBuilder { ... }

    #[cfg(feature="tokio")]
    pub fn builder_tokio(args: impl Into<TokioArgs>) -> FooBuilder { ... }
}

This approach ensures type safety at compile time and makes the runtime dependency explicit in the API surface. The resulting builder methods follow the pattern builder_{runtime}(args) where {runtime} indicates the specific runtime or execution environment.

Further Reading

Complex Type Initialization Hierarchies are Cascaded (M-INIT-CASCADED)

To prevent misuse and accidental parameter mix ups. 1.0

Types that require 4+ parameters should cascade their initialization via helper types.

struct Deposit;
impl Deposit {
    // Easy to confuse parameters and signature generally unwieldy.
    pub fn new(bank_name: &str, customer_name: &str, currency_name: &str, currency_amount: u64) -> Self { }
}

Instead of providing a long parameter list, parameters should be grouped semantically. When applying this guideline, also check if C-NEWTYPE is applicable:

struct Deposit;
struct Account;
struct Currency
impl Deposit {
    // Better, signature cleaner
    pub fn new(account: Account, amount: Currency) -> Self { }
}

impl Account {
    pub fn new_ok(bank: &str, customer: &str) -> Self { }
    pub fn new_even_better(bank: Bank, customer: Customer) -> Self { }
}



Services are Clone (M-SERVICES-CLONE)

To avoid composability issues when sharing common services. 1.0

Heavyweight service types and 'thread singletons' should implement shared-ownership Clone semantics, including any type you expect to be used from your Application::init.

Per thread, users should essentially be able to create a single resource handler instance, and have it reused by other handlers on the same thread:

impl ThreadLocal for MyThreadState {
    fn init(...) -> Self {

        // Create common service instance possibly used by many.
        let common = ServiceCommon::new();

        // Users can freely pass `common` here multiple times
        let service_1 = ServiceA::new(&common);
        let service_2 = ServiceA::new(&common);

        Self { ... }
    }
}

Services then simply clone their dependency and store a new handle, as if ServiceCommon were a shared-ownership smart pointer:

impl ServiceA {
    pub fn new(common: &ServiceCommon) -> Self {
        // If we only need to access `common` from `new` we don't have
        // to store it. Otherwise, make a clone we store in `Self`.
        let common = common.clone();
    }
}

Under the hood this Clone should not create a fat copy of the entire service. Instead, it should follow the Arc<Inner> pattern:

// Actual service containing core logic and data.
struct ServiceCommonInner {}

#[derive(Clone)]
pub ServiceCommon {
    inner: Arc<ServiceCommonInner>
}

impl ServiceCommon {
    pub fn new() {
        Self { inner: Arc::new(ServiceCommonInner::new()) }
    }

    // Method forwards ...
    pub fn foo(&self) { self.inner.foo() }
    pub fn bar(&self) { self.inner.bar() }
}



Accept impl AsRef<> Where Feasible (M-IMPL-ASREF)

To give users flexibility calling in with their own types. 1.0

In function signatures, accept impl AsRef<T> for types that have a clear reference hierarchy, where you do not need to take ownership, or where object creation is relatively cheap.

Instead of ...accept ...
&str, Stringimpl AsRef<str>
&Path, PathBufimpl AsRef<Path>
&[u8], Vec<u8>impl AsRef<[u8]>
use std::path::Path;
// Definitely use `AsRef`, the function does not need ownership.
fn print(x: impl AsRef<str>) {}
fn read_file(x: impl AsRef<Path>) {}
fn send_network(x: impl AsRef<[u8]>) {}

// Further analysis needed. In these cases the function wants
// ownership of some `String` or `Vec<u8>`. If those are
// "low freqency, low volume" functions `AsRef` has better ergonomics,
// otherwise accepting a `String` or `Vec<u8>` will have better
// performance.
fn new_instance(x: impl AsRef<str>) -> HoldsString {}
fn send_to_other_thread(x: impl AsRef<[u8]>) {}

In contrast, types should generally not be infected by these bounds:

// Generally not ok. There might be exceptions for performance
// reasons, but those should not be user visible.
struct User<T: AsRef<str>> {
    name: T
}

// Better
struct User {
    name: String
}



Accept impl RangeBounds<> Where Feasible (M-IMPL-RANGEBOUNDS)

To give users flexibility and clarity when specifying ranges. 1.0

Functions that accept a range of numbers must use a Range type or trait over hand-rolled parameters:

// Bad
fn select_range(low: usize, high: usize) {}
fn select_range(range: (usize, usize)) {}

In addition, functions that can work on arbitrary ranges, should accept impl RangeBounds<T> rather than Range<T>.

#![allow(unused)]
fn main() {
use std::ops::{RangeBounds, Range};
// Callers must call with `select_range(1..3)`
fn select_range(r: Range<usize>) {}

// Callers may call as
//     select_any(1..3)
//     select_any(1..)
//     select_any(..)
fn select_any(r: impl RangeBounds<usize>) {}
}



Accept impl 'IO' Where Feasible ('Sans IO') (M-IMPL-IO)

To untangle business logic from I/O logic, and have N*M composability. 0.1

Functions and types that only need to perform one-shot I/O during initialization should be written "sans-io", and accept some impl T, where T is the appropriate I/O trait, effectively outsourcing I/O work to another type:

// Bad, caller must provide a File to parse the given data. If this
// data comes from the network, it'd have to be written to disk first.
fn parse_data(file: File) {}
#![allow(unused)]
fn main() {
// Much better, accepts
// - Files,
// - TcpStreams,
// - Stdin,
// - &[u8],
// - UnixStreams,
// ... and many more.
fn parse_data(data: impl std::io::Read) {}
}

Synchronous functions should use std::io::Read and std::io::Write. Asynchronous functions targeting more than one runtime should use futures::io::AsyncRead and similar. Types that need to perform runtime-specific, continuous I/O should follow M-RUNTIME-ABSTRACTED instead.



Essential Functionality Should be Inherent (M-ESSENTIAL-FN-INHERENT)

To make essential functionality easily discoverable. 1.0

Types should implement core functionality inherently. Trait implementations should forward to inherent functions, and not replace them. Instead of this

#![allow(unused)]
fn main() {
trait Download {
    fn download_file(&self, url: impl AsRef<str>);
}
struct HttpClient {}

// Offloading essential functionality into traits means users
// will have to figure out what other traits to `use` to
// actually use this type.
impl Download for HttpClient {
    fn download_file(&self, url: impl AsRef<str>) {
        // ... logic to download a file
    }
}
}

do this:

#![allow(unused)]
fn main() {
trait Download {
    fn download_file(&self, url: impl AsRef<str>);
}
struct HttpClient {}

impl HttpClient {
    fn download_file(&self, url: impl AsRef<str>) {
        // ... logic to download a file
    }
}

// Forward calls to inherent impls. `HttpClient` can be used
impl Download for HttpClient {
    fn download_file(&self, url: impl AsRef<str>) {
        Self::download_file(self, url)
    }
}
}