This skill should be used when the user asks to "write Rust code", "create a Rust module", "implement a struct", "define a trait", "add error handling", "fix this Rust code", "refactor this Rust", "model this type in Rust", "use async/await", "write an async function", "handle lifetimes", "organize Rust modules", "set up a Rust project", "set up a Cargo workspace", "use serde", "configure tokio", "write Rust tests", or any task involving writing, reviewing, or improving Rust code. Provides best practices for the 2024 edition including type design, error handling, async patterns, trait design, lifetimes, module organization, and testing.
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.
Target the 2024 edition in new projects:
[package]
edition = "2024"
rust-version = "1.85"
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 or add keyword to individual items. |
unsafe externsafeunsafe_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. |
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.
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.
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.
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.
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.
thiserrorDefine 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.
anyhowUse 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.
| 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 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.
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.
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.
tokio::spawn requires Future + Send + 'static. Common fixes for Send errors:
MutexGuard across .await — drop the guard before awaiting.tokio::sync::Mutex when a lock must span an .await point.spawn_local for futures that cannot be Send.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.
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;
}
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.
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.
The compiler infers lifetimes in most function signatures:
&self or &mut self, its lifetime applies to all output references.&'a mut self on methods can over-constrain borrowing.String / Vec<u8> rather than &str / &[u8] when the callee needs to store the data. Use impl Into<String> for ergonomic conversion.Cow<'_, str> when a function sometimes borrows and sometimes allocates. See references/type-patterns.md for detailed Cow patterns.'static. It means the data lives for the entire program (string literals, leaked allocations, const values). Trait bounds like T: 'static mean the type contains no non-static references — it can still be a heap-allocated owned type.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.
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.
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.
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::*;.
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.
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
#[tokio::test]
async fn fetches_data() {
let result = fetch_data("test-key").await;
assert!(result.is_ok());
}
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);
}
}
Consider cargo-nextest for faster parallel execution and cleaner output: cargo nextest run.
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.
references/type-patterns.md — Advanced type design: generic newtypes, typestate with enums, sealed trait implementations, builder validation, Cow patterns, 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.