Libraries / UX Guidelines
Abstractions don't visibly nest (M-SIMPLE-ABSTRACTIONS)
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::itertypes likeChain,Cloned,Cycle, are not expected to be named.
- Service-level types are always expected to be named (e.g.,
- 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)
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)
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) { ... }
Errors are canonical structs (M-ERRORS-CANONICAL-STRUCTS)
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.
Canonical error conversion uses From, not map_err (M-FROM-ERROR)
Where an Error type is owned, it should impl From<Other> for Error {} instead of handling the conversion throughout the code via .map_error(). Calling .map_error() is only appropriate when dealing with foreign error types, or if contextual information needs to be preserved.
// Bad, repeats the same conversion at every call site and obscures the happy path.
fn load() -> Result<Config, MyError> {
let bytes = read("config.toml").map_err(|e| MyError::Io(e))?;
let text = str::from_utf8(&bytes).map_err(|e| MyError::Utf8(e))?;
let cfg = toml::from_str(text).map_err(|e| MyError::Parse(e))?;
Ok(cfg)
}
// Good, define the conversion once and let `?` apply it.
impl From<std::io::Error> for MyError { ... }
impl From<std::str::Utf8Error> for MyError { ... }
impl From<toml::de::Error> for MyError { ... }
fn load() -> Result<Config, MyError> {
let bytes = read("config.toml")?;
let text = str::from_utf8(&bytes)?;
let cfg = toml::from_str(text)?;
Ok(cfg)
}
Complex type construction has builders (M-INIT-BUILDER)
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 deps: impl Into<Deps> pattern to provide flexibility:
Note: A dedicated deps 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 deps 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 FooDeps {
pub logger: Logger,
pub config: Config,
}
impl From<(Logger, Config)> for FooDeps { ... }
impl From<Logger> for FooDeps { ... } // In case we could use default Config instance
impl Foo {
pub fn builder(deps: impl Into<FooDeps>) -> FooBuilder { ... }
}
This pattern allows for convenient usage:
Foo::builder(logger)- when only the logger is neededFoo::builder((logger, config))- when both parameters are neededFoo::builder(FooDeps { logger, config })- explicit struct construction
Alternatively, you can use fundle to simplify the creation of FooDeps:
#[derive(Debug, Clone)]
#[fundle::deps]
pub struct FooDeps {
pub logger: Logger,
pub config: Config,
}
This pattern enables "dependency injection", see these docs for more details.
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 SmolDeps {
pub clock: Clock,
pub io_context: Context,
}
#[cfg(feature="tokio")]
#[derive(Debug, Clone)]
pub struct TokioDeps {
pub clock: Clock,
}
impl Foo {
#[cfg(feature="smol")]
pub fn builder_smol(deps: impl Into<SmolDeps>) -> FooBuilder { ... }
#[cfg(feature="tokio")]
pub fn builder_tokio(deps: impl Into<TokioDeps>) -> 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}(deps) where {runtime} indicates the specific runtime or execution environment.
Further Reading
Complex type initialization hierarchies are cascaded (M-INIT-CASCADED)
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)
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() }
}
Essential functionality should be inherent (M-ESSENTIAL-FN-INHERENT)
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) } } }
Modules are balanced in size and scope (M-BALANCED-MODULES)
Your module design should approximately follow established UX practices of menu design: A reasonable number of your most important items should be placed in the crate root, and a comprehensible grouping of the remaining functionality into subordinate modules.
Two violations of that rule are encountered most frequently: flat module roots containing dozens of items without clear ordering, or the excessive use of submodules without items in the crate root. While there are crates where this makes sense (e.g., automatically generated -sys crates defining 100s of C items, or umbrella crates like std and tokio), the majority of library crates are not among them.
When designing your module layout, consider these factors:
- Essential items users must find in order to use a crate should go into its root. For example, a
foo_clientcrate should probably have its mainClientstruct inside the root. - Other items should be grouped semantically by use case. Modules named
traitsanderrorsdon't help anyone, butaccount,networkandstatusdo. - Also take into account that modules are the perfect place for module-level documentation that further explains the respective subsystem.
Don't define preludes (M-NO-PRELUDE)
Crates must not define a prelude or any namespace intended to be imported as use foo::*.
While the Rust Standard Library successfully uses preludes to define edition items, preludes in crates cause more harm than good. Given today's IDE support they are not needed, and once multiple preludes are used from different crates there is potential for conflicts:
use foo::prelude::*;
use bar::prelude::*;
use baz::prelude::*;
_ = Client::new();
// error[E0659]: `Client` is ambiguous
// --> src/lib.rs:17:13
// |
// 17 | _ = Client;
// | ^^^^^^ ambiguous name
// |
// = note: ambiguous because of multiple glob imports of a name in the same module
Preludes in particular do not resolve bad module design. If it looks like a prelude would make the crate easier to use or understand, this is almost always an indication that the existing module system needs restructuring, see M-BALANCED-MODULES.
Parameter ordering is consistent (M-PARAMETER-CONSISTENCY)
When the same conceptual parameters appear in multiple functions (within a crate or across crates in the same ecosystem), they should appear in the same order everywhere:
- important or call-specific parameters should generally go first,
- ubiquitous parameters rather go last (e.g.,
&logger), - closures always go last (functions should not accept more than one closure).
// Bad, the order of `user_id` and `tenant_id` flips between functions, and
// the logger sometimes appears first, sometimes last.
fn create_user(logger: &Logger, user_id: UserId, tenant_id: TenantId) -> Result<()> { ... }
fn delete_user(tenant_id: TenantId, user_id: UserId, logger: &Logger) -> Result<()> { ... }
fn rename_user(user_id: UserId, new_name: &str, tenant_id: TenantId, logger: &Logger) -> Result<()> { ... }
// Good, call-specific parameters first in a consistent order, ubiquitous
// `logger` always last.
fn create_user(tenant_id: TenantId, user_id: UserId, logger: &Logger) -> Result<()> { ... }
fn delete_user(tenant_id: TenantId, user_id: UserId, logger: &Logger) -> Result<()> { ... }
fn rename_user(tenant_id: TenantId, user_id: UserId, new_name: &str, logger: &Logger) -> Result<()> { ... }
Collections implement the appropriate iter traits (M-COLLECTION-TRAITS)
Custom collections should implement the iterator-facing traits the standard library offers.
Whenever you define a new collection type Collection<T> for consumption by third parties, the following traits and types should also be implemented, see here for more details:
- the structs
IntoIter<T>,Iter<T>andIterMut<T>, - an
impl Iteratorfor all of them, - the methods
c.iter()andc.iter_mut(), - an
impl IntoIteratorforCollection<T>,&Collection<T>and&mut Collection<T>, - an
impl FromIteratorforCollection<T>, - an
ExtendforCollection<T>, DoubleEndedIterator,ExactSizeIterator, ... as applicable
In addition, make sure you implement size_hint() on all iterators and do so truthfully.
Functions are async over returning a Future (M-ASYNC-FN)
Functions should be declared async fn foo() over fn foo() -> impl Future when both are viable.
Functions marked async are more idiomatic and easier to read. An explicit Future-returning signature should only be used when required, for example inside traits or for hot 'n heavy async functions, compare M-ASYNC-STACK-SIZE.
impl Foo {
// Bad, signature is noisier and the body needs an extra `async` block
fn foo() -> impl Future<Output = Result<T, E>> { async { Ok(t) } }
// Good, method and implementation reads normally
async fn foo() -> Result<T, E> { Ok(t) }
}