Rust
Best practices for writing idiomatic Rust targeting the 2024 edition (Rust 1.85+). Covers type design, error handling, async patterns, trait design, ownership and lifetimes, module organization, and testing.
Edition and Project Setup
Cargo.toml
Target the 2024 edition in new projects:
[package] edition = "2024" rust-version = "1.85"
Key 2024 Edition Changes
Changes that affect how code is written day-to-day:
| Change | Impact |
|---|---|
| RPIT lifetime capture | impl Trait return types capture all in-scope lifetime params by default. Use use<'a> to opt out. |
| Unsafe extern blocks | extern "C" { ... } items are implicitly unsafe. Wrap in unsafe extern or add safe keyword to individual items. |
unsafe_op_in_unsafe_fn warning | Unsafe operations inside unsafe fn now warn without an inner unsafe block. |
gen keyword reserved | Rename any identifiers named gen (use r#gen as escape hatch). |
| Prelude additions | Future and IntoFuture are in the prelude — no import needed. |
| Temporary scope changes | if let and tail expression temporaries drop before local variables. |
| Cargo MSRV-aware resolver | Cargo prefers dependency versions compatible with declared rust-version. |
Migration
Run cargo fix --edition before updating edition = "2024" in Cargo.toml. For large codebases, enable rust-2024-compatibility lints incrementally. Update code-generating crates (bindgen, cxx, proc-macros) first.
Type Design
Newtype Pattern
Wrap primitive types to create domain-specific types with zero runtime cost:
struct UserId(u64);
struct EmailAddress(String);
impl UserId {
fn new(id: u64) -> Self { Self(id) }
fn as_u64(&self) -> u64 { self.0 }
}
Newtypes prevent accidental interchange (UserId vs bare u64), enable trait implementations on foreign types (orphan rule workaround), and hide internal representation.
Discriminated Enums
Model variants with data as enums, not stringly-typed fields:
enum ConnectionState {
Disconnected,
Connecting { attempt: u32 },
Connected { session_id: String },
Failed { error: String, retries: u32 },
}
Match exhaustively. The compiler enforces handling of every variant.
Typestate Pattern
Encode state transitions in the type system to make invalid states unrepresentable:
struct Unvalidated;
struct Validated;
struct Form<State> {
data: FormData,
_state: std::marker::PhantomData<State>,
}
impl Form<Unvalidated> {
fn validate(self) -> Result<Form<Validated>, ValidationError> { /* ... */ }
}
impl Form<Validated> {
fn submit(self) -> Result<Response, SubmitError> { /* ... */ }
}
submit() is only callable on Form<Validated> — calling it on an unvalidated form is a compile error. Use sparingly; the complexity cost is justified when invalid state transitions cause serious bugs.
Builder Pattern
Use the builder pattern for types with many optional fields:
struct ServerConfig {
host: String,
port: u16,
max_connections: Option<usize>,
timeout: Option<Duration>,
}
impl ServerConfig {
fn builder(host: impl Into<String>, port: u16) -> ServerConfigBuilder {
ServerConfigBuilder {
host: host.into(),
port,
max_connections: None,
timeout: None,
}
}
}
impl ServerConfigBuilder {
fn max_connections(mut self, n: usize) -> Self {
self.max_connections = Some(n);
self
}
fn timeout(mut self, duration: Duration) -> Self {
self.timeout = Some(duration);
self
}
fn build(self) -> ServerConfig {
ServerConfig {
host: self.host,
port: self.port,
max_connections: self.max_connections,
timeout: self.timeout,
}
}
}
Required parameters go in the builder constructor. Optional parameters are set via chained methods consuming and returning Self. Call .build() to produce the final type. For critical state ordering, combine with the typestate pattern.
For detailed type patterns including generic newtypes, sealed traits, and advanced typestate, consult references/type-patterns.md.
Error Handling
Library Errors: thiserror
Define typed errors at library/crate boundaries:
use thiserror::Error;
#[derive(Debug, Error)]
enum StorageError {
#[error("record not found: {id}")]
NotFound { id: String },
#[error("connection failed")]
Connection(#[from] std::io::Error),
#[error("deserialization failed")]
Deserialize(#[source] serde_json::Error),
}
#[from] generates From impls for automatic ? conversion. #[source] preserves the error chain without generating From. Always derive Debug.
Application Errors: anyhow
Use anyhow at the application layer for ergonomic error aggregation:
use anyhow::{Context, Result};
fn load_config(path: &Path) -> Result<Config> {
let contents = std::fs::read_to_string(path)
.context("failed to read config file")?;
let config: Config = toml::from_str(&contents)
.context("failed to parse config")?;
Ok(config)
}
Always add .context() when propagating with ? — bare ? loses the "what was happening" information.
Guidelines
| Layer | Crate | Pattern |
|---|---|---|
| Library / public API | thiserror | Named error enum with #[from] / #[source] |
Application / main | anyhow | anyhow::Result<T> with .context() |
| Internal modules | Either | Match the layer's convention |
Do not over-engineer error types. If callers always handle variants the same way, fewer variants or #[error(transparent)] wrapping is better.
Async Patterns
async fn in Traits (Stable since 1.75)
trait DataStore {
async fn get(&self, key: &str) -> Result<Vec<u8>>;
async fn put(&self, key: &str, value: &[u8]) -> Result<()>;
}
Caveat: async trait methods are not dyn-compatible. Use the async-trait crate or manual Pin<Box<dyn Future>> desugaring when trait objects are needed.
Async Closures (Stable since 1.85)
let fetch = async |url: &str| {
reqwest::get(url).await?.text().await
};
Async closures return futures when called. The compiler uses AsyncFn, AsyncFnMut, and AsyncFnOnce traits internally — pass async closures directly rather than naming these traits in bounds.
Cancellation and Timeouts
use tokio::time::{timeout, Duration};
let result = timeout(Duration::from_secs(5), some_async_work()).await;
match result {
Ok(inner) => { /* inner is the Result from some_async_work */ }
Err(_) => { /* timed out */ }
}
Use tokio::select! for racing multiple futures. Use CancellationToken for structured cancellation across task trees.
Send + Sync Rules
tokio::spawn requires Future + Send + 'static. Common fixes for Send errors:
- •Don't hold
MutexGuardacross.await— drop the guard before awaiting. - •Use
tokio::sync::Mutexwhen a lock must span an.awaitpoint. - •Use
spawn_localfor futures that cannot beSend.
Blocking Work
Never run CPU-intensive or blocking I/O on the async runtime. Use tokio::task::spawn_blocking for blocking operations and tokio::sync::mpsc channels to communicate results back.
For detailed tokio channel types, select! patterns, and task management, consult references/api-reference.md.
Trait Design
Associated Types vs. Generics
Use associated types when there is one natural type per implementation:
trait Parser {
type Output;
fn parse(&self, input: &str) -> Result<Self::Output>;
}
Use generic parameters when a single type can implement the trait for multiple type arguments:
trait Convert<T> {
fn convert(&self) -> T;
}
Sealed Traits
Seal traits when implementations should only exist inside the current crate:
mod private { pub trait Sealed {} }
pub trait DatabaseDriver: private::Sealed {
fn connect(&self, url: &str) -> Result<Connection>;
}
This preserves the ability to add methods in non-breaking releases. Document that the trait is sealed.
Extension Traits
Add methods to foreign types via extension traits:
pub trait IteratorExt: Iterator {
fn count_where<P: Fn(&Self::Item) -> bool>(self, predicate: P) -> usize
where Self: Sized {
self.filter(predicate).count()
}
}
impl<I: Iterator> IteratorExt for I {}
#[diagnostic::do_not_recommend] (1.85+)
Annotate trait impls that produce confusing error messages when they fail to match:
#[diagnostic::do_not_recommend]
impl<T: ToString> From<T> for MyError {
fn from(value: T) -> Self {
MyError::Other(value.to_string())
}
}
The compiler will skip this impl in diagnostics and show a more helpful suggestion instead.
Ownership and Lifetimes
Elision Rules
The compiler infers lifetimes in most function signatures:
- •Each input reference gets a distinct lifetime.
- •If there is exactly one input lifetime, it applies to all output references.
- •If one input is
&selfor&mut self, its lifetime applies to all output references. - •Otherwise, annotate explicitly.
Practical Guidelines
- •Let elision work. Do not annotate lifetimes that the compiler can infer. Unnecessary annotations like
&'a mut selfon methods can over-constrain borrowing. - •Scope borrows narrowly. Extract the borrow into a block or helper to release it before other operations on the same data.
- •Prefer owned data at API boundaries. Accept
String/Vec<u8>rather than&str/&[u8]when the callee needs to store the data. Useimpl Into<String>for ergonomic conversion. - •Use
Cow<'_, str>when a function sometimes borrows and sometimes allocates. Seereferences/type-patterns.mdfor detailedCowpatterns. - •Understand
'static. It means the data lives for the entire program (string literals, leaked allocations,constvalues). Trait bounds likeT: 'staticmean the type contains no non-static references — it can still be a heap-allocated owned type.
Module Organization
Single Crate Layout
my_project/
├── Cargo.toml
└── src/
├── main.rs # binary entry point
├── lib.rs # library root (optional, for testable public API)
├── config.rs
├── models/
│ ├── mod.rs
│ └── user.rs
└── services/
├── mod.rs
└── auth.rs
Expose a public API from lib.rs and keep main.rs thin — call into the library.
Workspace Layout
For multi-crate projects:
my_project/
├── Cargo.toml # [workspace] definition
├── Cargo.lock
└── crates/
├── core/ # domain logic
├── cli/ # CLI binary
└── api/ # HTTP API binary
Define shared dependencies in [workspace.dependencies] and reference them with dep.workspace = true in member crates.
Visibility
- •Default: private to the module.
- •
pub: public to all. - •
pub(crate): visible within the crate only. - •
pub(super): visible to the parent module.
Prefer pub(crate) for internal APIs that need cross-module access but should not be part of the public API.
Preludes
For crates with many commonly-used types, provide a prelude module:
// src/prelude.rs
pub use crate::config::Config;
pub use crate::error::{AppError, Result};
pub use crate::models::User;
Consumers import with use my_crate::prelude::*;.
Testing
Unit Tests
Keep unit tests in the same file as the code under test:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_valid_input() {
let result = parse("valid input");
assert_eq!(result, Ok(Expected));
}
}
#[cfg(test)] compiles only under cargo test. Tests within the module can access private items.
Integration Tests
Place integration tests in a top-level tests/ directory. Each file compiles as a separate crate and can only use the public API:
tests/
├── api_tests.rs
└── common/
└── mod.rs # shared test helpers
Testing Async Code
#[tokio::test]
async fn fetches_data() {
let result = fetch_data("test-key").await;
assert!(result.is_ok());
}
Property Testing
Use proptest for input-space exploration:
use proptest::prelude::*;
proptest! {
#[test]
fn roundtrip_serialization(input in ".*") {
let encoded = encode(&input);
let decoded = decode(&encoded).unwrap();
assert_eq!(input, decoded);
}
}
Test Runner
Consider cargo-nextest for faster parallel execution and cleaner output: cargo nextest run.
Recent Stable Features
Key features stabilized in the 1.75–1.85 range:
| Version | Feature |
|---|---|
| 1.75 | async fn in traits, RPITIT |
| 1.77 | C-string literals, async recursive calls |
| 1.79 | Inline const blocks, bounds on associated types in bounds |
| 1.80 | LazyCell / LazyLock (replaces lazy_static / once_cell), exclusive range patterns |
| 1.81 | #[expect] lint level, Error trait in core |
| 1.82 | Apple ARM tier 1, inline asm const |
| 1.84 | Cargo MSRV-aware resolver, strict pointer provenance |
| 1.85 | 2024 edition, async closures, #[diagnostic::do_not_recommend] |
Prefer LazyLock over the lazy_static or once_cell crates. Prefer #[expect(lint)] over #[allow(lint)] — the former warns when the suppression becomes unnecessary.
Additional Resources
- •
references/type-patterns.md— Advanced type design: generic newtypes, typestate with enums, sealed trait implementations, builder validation,Cowpatterns, and zero-cost abstraction techniques. - •
references/api-reference.md— Quick reference for recently stabilized std library APIs, common crate APIs (tokio,serde,thiserror,anyhow), and 2024 edition patterns with code examples.