
Rust Best Practices
- 57 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
rust-best-practices is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- rust-best-practices
- AI & Agent Building
- AI-coding skill
Rust Best Practices by the numbers
- 57 all-time installs (skills.sh)
- Ranked #6,669 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill rust-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 57 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
Rust Best Practices
Guidance for writing idiomatic, performant, and safe Rust code. This is a development skill, not a review skill -- use it when building, not reviewing.
Quick Reference
| Topic | Key Rule | Reference |
|---|---|---|
| Ownership | Borrow by default, clone only when you need a separate owned copy | references/coding-idioms.md |
| Clippy | Run cargo clippy -- -D warnings on every commit; configure workspace lints | references/clippy-config.md |
| Performance | Don't guess, measure. Profile with --release first. Watch monomorphization + cache-line alignment at scale | references/performance.md |
| Generics | Static dispatch by default, dynamic dispatch when you need mixed types | references/generics-dispatch.md |
| Type State | Encode state in the type system when invalid operations should be compile errors | references/type-state-pattern.md |
| Documentation | // for why, /// for what and how, //! for module/crate purpose | references/documentation.md |
| Pointers | Choose pointer types based on ownership needs and threading model | references/pointer-types.md |
| API Design | Unsurprising, flexible, obvious, constrained — encode invariants in types; watch hidden contracts (re-exports, auto-traits) | references/api-design.md |
| Wild Patterns | Drop guards, extension traits, index pointers, crate preludes — battle-tested idioms from mature crates | references/coding-idioms.md |
| Ecosystem | Evaluate crates, pick error handling strategy, stay current | references/ecosystem-patterns.md |
Gates
Short sequences with pass conditions before claiming outcomes that need evidence (not an internal “I checked”).
Clippy clean
1. From the workspace root (or with -p <crate>), run: cargo clippy --all-targets --all-features -- -D warnings. 2. Pass: exit status is 0 and the invocation finishes without Clippy-deny failures.
Performance claim
1. Build with cargo build --release (or your benchmark harness) under the same profile you ship or measure. 2. Capture a before and after number from the same tool and metric (name both), e.g. Criterion ns/iter, heaptrack allocations, or a flamegraph path on disk. 3. Pass: you can cite both measurements, or you explicitly state that only correctness or readability changed and you are not claiming a performance delta.
Docs for symbols you changed
1. Run cargo doc --no-deps for the crate you edited (add -p <crate> in workspaces). 2. Pass: the doc build succeeds; if #![deny(missing_docs)] (or crate policy) applies, there are no new missing-doc errors for those symbols.
Coding Idioms
Prefer &T over .clone(), use &str/&[T] in parameters, and chain iterators instead of index-based loops. For Option/Result, use let Ok(x) = expr else { return } for early returns and ? for propagation. For scoped state changes, use drop guards (let _guard = ..., never let _ = ...) with mem::replace or scopeguard::defer!. Add methods to foreign types via extension traits (trait MyExt; impl<T: Bound> MyExt for T). For graph and tree shapes, prefer index pointers (slotmap::DefaultKey, generational indices) over &T to side-step lifetimes without unsafe. Curate a lean crate prelude for ergonomic glob imports; prelude additions are semver-minor (RFC 1105). See references/coding-idioms.md for ownership, iterator, import patterns, and these ecosystem-level idioms.
Error Handling
Return Result<T, E> for fallible operations. Use thiserror for library error types, anyhow for binaries. Propagate with ?, never unwrap() outside tests. See references/coding-idioms.md for Option/Result patterns.
Clippy Discipline
Run cargo clippy --all-targets --all-features -- -D warnings on every commit. Configure workspace lints in Cargo.toml and use #[expect(clippy::lint)] (not #[allow]) as the standard for lint suppression -- it warns when the suppression becomes stale. See references/clippy-config.md for lint configuration and key lints.
Performance Mindset
Always benchmark with --release, profile before optimizing, and avoid cloning in loops or premature .collect() calls. Keep small types on the stack and heap-allocate only recursive structures and large buffers. For workspaces at scale, watch monomorphization budgets (extract type-independent inner functions; switch internal generics to dyn where peak inlining isn't critical) and false sharing (#[repr(align(64))] or crossbeam::utils::CachePadded on per-thread atomics; align(128) on Apple Silicon). Benchmark with criterion — persist a baseline (--save-baseline main) and compare in CI, use criterion::black_box (with as_ptr() for pointer inputs), and isolate I/O into iter_batched setup. See references/performance.md for profiling tools, allocation guidance, monomorphization patterns, cache-line alignment, and criterion discipline.
Generics and Dispatch
Use static dispatch (impl Trait / <T: Trait>) by default for zero-cost monomorphization. Switch to dyn Trait only for heterogeneous collections or plugin architectures, preferring &dyn Trait over Box<dyn Trait> when ownership isn't needed. In edition 2024, -> impl Trait captures all in-scope lifetimes by default -- use + use<'a, T> for precise capture control. Prefer native async fn in traits over the async-trait crate for static dispatch. See references/generics-dispatch.md for dispatch trade-offs, RPIT capture rules, and async trait guidance.
Type State Pattern
Encode valid states in the type system so invalid operations become compile errors. Use for builders with required fields, protocol state machines, and workflow pipelines. See references/type-state-pattern.md for implementation patterns and when to avoid.
Documentation
Use // for why, /// for what/how on public APIs, and //! for module purpose. Every TODO needs a linked issue and library crates should enable #![deny(missing_docs)]. Use #[diagnostic::on_unimplemented] to provide custom compiler errors for your public traits. See references/documentation.md for doc test patterns, comment conventions, and diagnostic attributes.
API Design
Follow four principles: unsurprising (reuse standard names and traits), flexible (use generics and impl Trait to avoid unnecessary restrictions), obvious (encode invariants in the type system so misuse is a compile error), and constrained (expose only what you can commit to long-term). Use #[non_exhaustive] for types that may grow, seal traits you need to extend without breaking changes, and wrap foreign types in newtypes to control your SemVer surface. Watch for hidden contracts — re-exported foreign types, auto-trait propagation through -> impl Trait, and accidental !Send futures — and lock them down with a compile-time is_normal<T: Sized + Send + Sync + Unpin>() test for public types. Ship new traits with blanket impls for &T/Box<T> early (adding later is breaking). For fallible cleanup, expose an explicit close()/shutdown() returning Result; Drop cannot fail or .await. See references/api-design.md for builder patterns, sealed traits, object-safety mechanics, Deref discipline, fallible destructors, and SemVer implications.
Ecosystem Patterns
Evaluate crates by recent download trends, maintenance activity, documentation quality, and transitive dependency weight. Use thiserror for library error types, anyhow for binaries, and eyre when you need custom error reporters. Prefer vendoring or writing code yourself when a crate pulls heavy dependencies for a small feature. Run cargo-deny for license and vulnerability auditing and cargo-udeps to trim unused dependencies. See references/ecosystem-patterns.md for crate evaluation criteria, edition migration, and essential tooling.
Pointer Types
Choose pointer types based on ownership and threading: Box<T> for single-owner heap allocation, Rc<T>/Arc<T> for shared ownership, Cell/RefCell/Mutex/RwLock for interior mutability. Use LazyLock/LazyCell (stable since 1.80) instead of lazy_static or once_cell. See references/pointer-types.md for the full single-thread vs multi-thread decision table and migration guidance.
Destructors and Cleanup
Drop::drop(&mut self) cannot return an error or .await. For fallible cleanup (I/O flush, network shutdown, async commit), expose an explicit close() or shutdown() method returning Result<(), Error> (or impl Future) and run best-effort cleanup in Drop as a fallback. Patterns for "consume self in Drop": Option<T>-newtype with mem::take, per-field mem::take, or ManuallyDrop<T>. Never block_on(...) in Drop (deadlock under async runtimes). For scoped state changes (toggle, restore, run on panic), use a drop guard bound with let _guard = ... — never let _ = ..., which drops immediately. scopeguard::defer! is the battle-tested option; note that drop guards do NOT run under panic = "abort". See references/coding-idioms.md and references/api-design.md for the explicit-destructor pattern.
Async APIs
async fn lowers to a state machine returning an anonymous impl Future. Lock down the Send-ness contract on public APIs with -> impl Future<...> + Send + '_ (or an explicit lifetime tied to &self) — auto-trait propagation through -> impl Trait is silent and a single Rc<...> or std::sync::MutexGuard held across .await downgrades the whole future to !Send, breaking downstream callers. Add + 'static only when the future must be spawned onto a multi-threaded executor (e.g. tokio::spawn); a blanket 'static on every signature forbids borrowing from &self and is the wrong default. Drop equals cancel: when a future is dropped mid-poll, locals drop, no cleanup runs. Document cancel-safety on every public async fn: cancel-safe (recv, observation-only) vs cancel-unsafe (read_exact, write_all, anything holding cross-poll invariants). For runtime-agnostic library code, take impl Future or use futures crate primitives — do NOT spawn internally, and document the required runtime. Use std::pin::pin! macro for stack-pinned local futures; Box::pin only when heap allocation is acceptable.
API Design
Four Principles
Every Rust interface should be unsurprising (follow naming conventions and standard trait expectations), flexible (avoid unnecessary restrictions on callers), obvious (use types and docs to prevent misuse), and constrained (expose only what you intend to support long-term).
Naming Conventions
Follow the Rust API Guidelines. Reuse well-known names so users can rely on intuition:
itertakes&selfand returns an iteratorinto_innertakesselfand returns a wrapped valueSomethingErrorimplementsstd::error::Error
Avoid using familiar names for unfamiliar behavior -- if iter takes self, users will write bugs.
Standard Trait Implementations
Eagerly implement standard traits even if you don't need them yet. Users cannot implement foreign traits on your types due to coherence rules.
Priority order: 1. Debug -- nearly every type should have it 2. Send, Sync -- document if intentionally missing 3. Clone, Default -- expected for most types 4. PartialEq, Hash -- needed for collections and assertions 5. Serialize/Deserialize -- behind a serde feature flag
Avoid deriving Copy by default. It changes move semantics, and removing it later is a breaking change.
Making Invalid States Unrepresentable
Use the type system to prevent misuse at compile time rather than panicking at runtime.
// BAD -- runtime check, caller can pass wrong combination
fn launch(rocket: &mut Rocket, is_fueled: bool, is_on_ground: bool) {
assert!(is_fueled && is_on_ground);
}
// GOOD -- invalid calls are compile errors
struct Grounded;
struct Launched;
struct Rocket<Stage = Grounded> {
stage: std::marker::PhantomData<Stage>,
}
impl Rocket<Grounded> {
fn launch(self) -> Rocket<Launched> { /* ... */ }
}Combine related booleans into enums. If a pointer is only valid when a flag is true, use an Option or a single enum with the data inside the relevant variant.
Builder Pattern
Use when constructing types with many optional fields or when required fields must be validated before use.
pub struct ServerConfig { /* private fields */ }
pub struct ServerConfigBuilder {
host: String,
port: Option<u16>,
tls: Option<TlsConfig>,
}
impl ServerConfigBuilder {
pub fn new(host: impl Into<String>) -> Self {
Self { host: host.into(), port: None, tls: None }
}
pub fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
pub fn build(self) -> Result<ServerConfig, ConfigError> {
// validate and construct
}
}For builders with required stages, combine with type-state pattern (see type-state-pattern.md).
impl Trait in Argument vs Return Position
Argument position (fn foo(x: impl Read)) -- syntactic sugar for generics. Caller chooses the concrete type. Monomorphized, so each type gets its own copy.
Return position (fn foo() -> impl Read) -- caller does not choose the type. Useful for hiding complex return types (iterators, closures, futures). In edition 2024, captures all in-scope lifetimes by default.
// Argument position: caller picks the type
fn process(input: impl AsRef<str>) { /* ... */ }
// Return position: hides the concrete iterator type
fn even_squares(nums: &[i32]) -> impl Iterator<Item = i32> + '_ {
nums.iter().filter(|n| *n % 2 == 0).map(|n| n * n)
}Prefer generics over impl Trait in arguments when the same type parameter is referenced multiple times or when the caller needs turbofish syntax.
Sealed Traits
Prevent external crates from implementing your trait while still allowing them to use it. Useful for derived/blanket traits and restricting type parameters.
pub trait Stage: sealed::Sealed { /* ... */ }
mod sealed {
pub trait Sealed {}
impl Sealed for super::Grounded {}
impl Sealed for super::Launched {}
}Document that the trait is sealed so users don't waste time trying to implement it.
Newtype Pattern
Wrap a type to give it distinct semantics or work around the orphan rule.
// Semantic distinctness
struct Meters(f64);
struct Seconds(f64);
// Orphan rule workaround -- implement foreign trait for foreign type
struct PrettyVec<T>(Vec<T>);
impl<T: Display> Display for PrettyVec<T> { /* ... */ }Use Deref to forward method calls to the inner type when the wrapper is transparent.
Non-Exhaustive for Forward Compatibility
Prevent downstream code from constructing your types directly or matching exhaustively, so you can add fields/variants without a breaking change.
#[non_exhaustive]
pub enum Error {
NotFound,
PermissionDenied,
}
#[non_exhaustive]
pub struct Config {
pub timeout_ms: u64,
}Use when the type is likely to gain new variants or fields. Avoid on stable types where exhaustive matching is valuable to callers.
Blanket Implementations
Provide blanket impls for references when your trait has only &self methods:
impl<T: MyTrait> MyTrait for &T { /* forward calls */ }
impl<T: MyTrait> MyTrait for Box<T> { /* forward calls */ }This lets fn foo(x: impl MyTrait) accept both owned values and references without surprises. Adding a blanket impl later is a breaking change due to coherence, so plan early.
SemVer Implications
Breaking changes (require major version bump):
- Removing or renaming public items
- Adding fields to a non-
#[non_exhaustive]struct - Removing a trait implementation
- Adding a blanket trait implementation
- Making a trait no longer object-safe
- Changing auto-trait implementations (Send, Sync)
- Bumping a major version of a re-exported dependency
Non-breaking changes:
- Adding new public items
- Adding a trait method with a default implementation
- Implementing a trait for a new type
- Relaxing trait bounds on existing functions
Use #[non_exhaustive] on types you expect to evolve, seal traits you need to extend, and wrap re-exported foreign types in newtypes.
Hidden Contracts
Some breaking changes do not show up in your signatures. The review-side companion in ../../rust-code-review/references/interface-design.md catches these in diffs; on the development side, design around them up front.
Re-exported foreign types. pub use serde::Serialize; (or returning serde_json::Value from a public function) silently makes serde's major version part of your API contract. When serde bumps 1.x to 2.x, downstream code that mixes your crate with serde 1.x sees serde1::Value and serde2::Value as different types. Three defenses:
- Don't re-export. Keep foreign types out of your public surface.
- Wrap the foreign type in a newtype and expose only the methods you commit to.
- Return
impl Traitwith a minimal bound (impl Iterator<Item = T>rather thanitercrate::Empty<T>) so the concrete foreign type is invisible.
Auto-trait propagation. Send, Sync, Unpin, UnwindSafe are inferred by the compiler from a type's contents and propagated through -> impl Trait and async fn bodies. A function whose body holds an Rc<...> across an .await silently downgrades its returned future to !Send, and downstream code that tokio::spawns it stops compiling. The auto-trait status is part of your contract even though it appears nowhere in the signature.
To lock the contract, name it explicitly on the return type:
pub fn run(&self) -> impl Future<Output = ()> + Send + Sync + 'static {
async move { /* compiler enforces Send + Sync */ }
}The `is_normal` compile-time test. Pin every important public type so silent auto-trait regressions fail CI rather than downstream builds:
#[cfg(test)]
fn is_normal<T: Sized + Send + Sync + Unpin>() {}
#[test]
fn public_types_are_normal() {
is_normal::<MyType>();
is_normal::<MyHandle>();
}The test runs no code; it just fails to compile if any listed type loses an auto-trait.
Object Safety Mechanics
A trait is object-safe (usable as dyn Trait) only if every method satisfies all of:
1. No `Self` by value or as return type. Rules out Clone::clone(&self) -> Self. The vtable doesn't know Self's size. 2. No generic type parameters on methods. Rules out Extend::extend<I: IntoIterator>. One vtable slot cannot hold infinite monomorphizations. 3. A `self`/`&self`/`&mut self`/`Box<Self>` receiver. Rules out Default::default() -> Self and other associated functions — no receiver means no impl to pick. 4. `Sized` must be a non-required supertrait. dyn Trait is unsized; if the trait demands Self: Sized, no dyn exists. 5. No associated constants (without where Self: Sized) — same reason as static methods.
Escape hatch: tag the offending method where Self: Sized. The method becomes unavailable through dyn Trait, but the rest of the trait stays object-safe. Iterator uses this trick — map, filter, collect, etc. all carry where Self: Sized, which is why Box<dyn Iterator<Item = T>> works for next() even though most combinators are gone.
If a method genuinely needs a generic, three options before giving up on object safety:
// Option A: lift the generic to the trait
trait Sink<I> { fn extend(&mut self, iter: I); }
// Option B: take a trait object inside the method
trait Sink { fn extend(&mut self, iter: &mut dyn Iterator<Item = u8>); }
// Option C: opt that one method out of object safety
trait Sink { fn extend<I: IntoIterator<Item = u8>>(&mut self, iter: I) where Self: Sized; }Wrapper Types and Deref Discipline
Deref is appropriate when access is cheap and the wrapper is transparent — the user should be unable to tell the difference between calling a method on the wrapper and on the inner type. Good fits:
- Smart pointers:
Box<T>,Rc<T>,Arc<T>,MutexGuard<'_, T>. - Transparent newtypes wrapping a single inner value with no behavior change (
Stringderefs to&str).
Deref is wrong when:
- You want OOP-style inheritance. Rust has none, and
Derefambiguity bites: if both your wrapper and itsDeref::Targethave inherent methods namedfrobnicate,wrapper.frobnicate()is unambiguous to the compiler but not to the reader. If the target is a user-controlled type, any inherent method you add can later clash with one the user adds. Prefer static-form methods (fn frobnicate(w: Wrapper)) when forwarding is the goal. - Access requires complex or expensive logic.
Derefis implicit; users won't expect.fieldto allocate or hit the network. Provide an explicit accessor instead. - You are adding behavior rather than forwarding. Use ordinary inherent methods.
Borrow<T> vs AsRef<T> vs Deref<Target = T> are three different contracts. Borrow requires that the wrapper produces identical Hash, Eq, and Ord results to the inner — that is why HashSet<String> accepts &str lookups via Borrow. Treat Borrow as "equivalent for collection keys," not as a general "I can be referenced as." AsRef is the right trait for cheap reference-to-reference conversions without the hash/eq invariant. Deref is for transparent dot-operator forwarding.
For a transparent wrapper, eagerly provide all of: Deref, AsRef<Inner>, From<Inner>, Into<Inner>.
Fallible and Blocking Destructors
Drop::drop(&mut self) cannot return an error and cannot .await. For I/O-flavored types this is a real design constraint. Patterns:
Explicit `close()` / `shutdown()` method taking self by value and returning Result<(), Error> (or impl Future<Output = Result<...>>). Drop runs as best-effort fallback only. Document the explicit destructor prominently — users won't find it by reading method signatures.
impl Connection {
pub fn close(self) -> Result<(), CloseError> { /* surfaces errors */ }
}
impl Drop for Connection {
fn drop(&mut self) { let _ = self.try_close_sync(); }
}The "Drop blocks moves out of self" trap. Once you implement Drop, you cannot move fields out of self in other methods, because Drop::drop(&mut self) still runs afterward with all fields required intact. Three workarounds:
- `Option<T>` field: replace fields (or the whole inner) with
Option, thenmem::take(&mut self.inner)(orOption::take) in bothDropand the explicit destructor. Cost: every field access becomes an unwrap. - `std::mem::take` with a cheap
Default: swap out the live value during destruction. Tidy when fields have sensible empty defaults. - `ManuallyDrop<T>`: hold the inner in
ManuallyDrop<Inner>for full manual control.ManuallyDrop::takeisunsafe— double-take or use-after-take is UB. Use only when the code is simple enough to statically verify.
Never `block_on` in `Drop`. Spawning a runtime or blocking on one inside a destructor deadlocks under async runtimes and races executor shutdown. Provide an explicit async fn close(self) -> Result<...> and document that callers must invoke it.
Ergonomic Blanket Impls
Rust does not auto-implement traits for references or smart pointers. So fn f<T: MyTrait>(t: T) rejects &Wrapper even when Wrapper: MyTrait. When you define a new trait, eagerly add the blanket impls users expect — adding them later is a breaking change because of coherence rules (downstream impls may already overlap).
For a trait whose methods all take &self:
impl<T: MyTrait + ?Sized> MyTrait for &T { /* forward */ }
impl<T: MyTrait + ?Sized> MyTrait for &mut T { /* forward */ }
impl<T: MyTrait + ?Sized> MyTrait for Box<T> { /* forward */ }Which blankets are possible depends on the receivers. &mut self rules out &T. self rules out all three. Include ?Sized so unsized types (dyn, str, slices) work too.
For iterable collections, also provide both reference forms of IntoIterator so for x in &c and for x in &mut c work as users expect from Vec and HashMap:
impl<'a, T> IntoIterator for &'a MyCollection<T> { /* type Item = &'a T; */ }
impl<'a, T> IntoIterator for &'a mut MyCollection<T> { /* type Item = &'a mut T; */ }Standard Derives Priority — Ordered List
The intro section above lists the basics. Jon's full ordering with caveats:
- `Debug` — first, nearly every type. If
#[derive(Debug)]adds an unwantedT: Debugbound (e.g.,Tis only used asPhantomData), hand-write the impl viaf.debug_struct(...). - `Send` / `Sync` / `Unpin` — auto-derived from contents. If a type is intentionally
!Sendor!Sync, document why in rustdoc. Non-Sendtypes cannot live inMutexor tokio tasks; non-Synctypes cannot live inArcorstatic. - `Clone` / `Default` — expected for most types. Easy to derive; document explicitly if a type cannot implement them.
- `PartialEq` — high value; users want
assert_eq!. Worth implementing even when equality is reflexive-only. - `Eq` / `Hash` — for map and set keys.
Eqcarries reflexivity beyondPartialEq; only add when the semantics hold. - `PartialOrd` / `Ord` — only when a natural total order exists. Most types don't qualify.
- `Serialize` / `Deserialize` — gate behind
#[cfg_attr(feature = "serde", derive(...))]so consumers opt into the serde dependency.
Avoid `Copy` by default. Users expect to call .clone() for a second copy. Copy changes move semantics and is highly restrictive — a type that starts simple often grows to hold a String, and removing Copy is a breaking change. Clone is far easier to keep stable across versions.
Clippy Configuration
Daily Workflow Command
Run on every commit or PR:
cargo clippy --all-targets --all-features --locked -- -D warnings--all-targets: checks library, tests, benches, examples--all-features: enables all feature flags to catch conditional code--locked: requires Cargo.lock to be up-to-date (omit for library crates that don't commitCargo.lock)-D warnings: treats warnings as errors
Add to your Makefile, Justfile, xtask, or CI pipeline.
Workspace Lint Configuration
Configure in Cargo.toml for consistent enforcement:
[workspace.lints.rust]
future-incompatible = "warn"
nonstandard_style = "deny"
[workspace.lints.clippy]
all = { level = "deny", priority = 10 }
redundant_clone = { level = "deny", priority = 9 }
manual_while_let_some = { level = "deny", priority = 4 }
pedantic = { level = "warn", priority = 3 }Individual crates inherit workspace lints:
[lints]
workspace = trueHigher priority numbers win when lints conflict.
Key Lints to Enforce
| Lint | Why | Category |
|---|---|---|
redundant_clone | Detects unnecessary clones with performance impact | perf |
large_enum_variant | Warns about oversized variants -- consider Boxing | perf |
needless_collect | Prevents unnecessary intermediate collection allocation | nursery |
needless_borrow | Removes redundant & borrowing | style |
clone_on_copy | Catches .clone() on Copy types like u32 | complexity |
unnecessary_wraps | Function always returns Some/Ok -- drop the wrapper | pedantic |
manual_ok_or | Suggests .ok_or_else() over match | style |
Run the perf lint group specifically:
cargo clippy -- -D clippy::perfLint Suppression
Use #[expect] as the Default
#[expect] (stable since Rust 1.81) is the standard for lint suppression. It warns when the lint no longer triggers, preventing stale suppressions from accumulating. Always use #[expect] instead of #[allow]:
// BAD - stale allow stays forever unnoticed
#[allow(clippy::large_enum_variant)]
enum Message { /* ... */ }
// GOOD - compiler warns when lint is no longer needed
#[expect(clippy::large_enum_variant)]
enum Message { /* ... */ }When migrating an existing codebase, replace all #[allow(lint)] with #[expect(lint)]. The compiler will immediately flag any suppressions that are no longer needed, letting you clean up dead lint suppression.
For crate-level suppression where #![expect(...)] is not practical (e.g., generated code), #![allow(...)] remains acceptable with a comment explaining why.
Always Add Justification
// Intentionally large for cache-line alignment
#[expect(clippy::large_enum_variant, reason = "cache-line alignment")]
enum Packet {
Header(u8),
Payload([u8; 1024]),
}The reason parameter (stable since 1.81) documents intent inline and shows up in compiler output when the expect becomes unfulfilled.
Handling False Positives
1. Try refactoring to satisfy the lint 2. If the code is genuinely correct, suppress locally with #[expect] and a reason 3. Avoid global suppression unless it is a known framework issue (e.g., Bevy engine patterns)
CI Integration
Add clippy to your CI pipeline:
# GitHub Actions (application/binary crates that commit Cargo.lock)
- name: Clippy
run: cargo clippy --all-targets --all-features --locked -- -D warnings
# Library crates (Cargo.lock not committed — omit --locked)
- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warningsConsider adding pedantic and nursery for stricter checks:
cargo clippy -- -W clippy::pedantic -W clippy::nurseryOptional Stricter Lints
| Group | When to Use |
|---|---|
pedantic | Strict style checks, occasional false positives |
nursery | New lints under development, may be noisy |
perf | Performance-focused checks (always recommended) |
restriction | Very strict, use selectively (e.g., clippy::unwrap_used) |
Coding Idioms
Borrowing Over Cloning
Default to borrowing (&T). Clone only when you genuinely need a separate owned copy.
When Clone is Appropriate
- Shared ownership via
Arc::clone(&arc)(cheap atomic increment) - Immutable snapshots where the original must be preserved
- When the API requires owned data and the caller still needs the original
- Caching results returned multiple times
Clone Traps to Avoid
// BAD - cloning to avoid lifetime annotations
fn process(thing: &Thing) {
let owned = thing.clone(); // if you need ownership, take it in the signature
consume(owned);
}
// GOOD - take ownership explicitly
fn process(thing: Thing) {
consume(thing);
}// BAD - cloning inside iterator
items.iter().map(|x| x.clone()).collect::<Vec<_>>();
// GOOD - use .cloned() or .copied()
items.iter().cloned().collect::<Vec<_>>();
items.iter().copied().collect::<Vec<_>>(); // for Copy typesPrefer Borrowed Parameters
// DO - borrow when you only read
fn greet(name: &str) {
println!("Hello, {name}");
}
// DO - use slices over owned collections
fn sum(values: &[i32]) -> i32 {
values.iter().sum()
}
// DON'T - force callers to allocate
fn greet(name: String) {
println!("Hello, {name}");
}For maximum flexibility in public APIs, use impl AsRef<str> or impl Into<String>.
Copy Trait
When to Derive Copy
- All fields are
Copythemselves - Struct is small (<=24 bytes / 2-3 machine words)
- Type is plain data without heap allocations
// GOOD - small plain data
#[derive(Debug, Copy, Clone)]
struct Point { x: f32, y: f32, z: f32 }
// GOOD - tag-like enum
#[derive(Debug, Copy, Clone)]
enum Direction { North, South, East, West }
// CAN'T - String is not Copy
struct User { age: i32, name: String }Enum size equals the largest variant. Keep variants small or Box large payloads.
Option and Result Handling
Pattern Selection
| Pattern | Use When |
|---|---|
let Ok(x) = expr else { return ... } | Early return, divergent code doesn't need error value |
match | Pattern matching inner variants, transforming Result/Option shapes |
if let ... else | Else branch needs computation with the value |
? operator | Propagating errors to the caller |
// Early return with let-else
let Some(config) = load_config() else {
return Err(AppError::MissingConfig);
};
// Pattern matching inner variants
match result {
Ok(Direction::North) => handle_north(),
Ok(other) => handle_other(other),
Err(e) => handle_error(e),
}
// Propagation with ?
fn process(req: &Request) -> Result<Response, Error> {
let body = validate(req)?;
let user = authorize(&body)?;
Ok(handle(user)?)
}Avoid These
// BAD - unwrap in production
let port = config.port.unwrap();
// BAD - match that should be .ok() or .ok_or()
match result {
Ok(v) => Some(v),
Err(_) => None,
}
// GOOD
result.ok()Prevent Early Allocation
Use _or_else variants when the fallback involves allocation:
// BAD - format! runs even when x is Some
x.ok_or(ParseError::Detail(format!("missing {name}")));
// GOOD - only allocates on the error path
x.ok_or_else(|| ParseError::Detail(format!("missing {name}")));
// BAD - Vec::new() always allocates
values.unwrap_or(Vec::new());
// GOOD - uses Default trait
values.unwrap_or_default();Iterator Patterns
Prefer Iterator Chains For
- Collection transforms:
.filter().map().collect() - Combining:
.enumerate(),.chain(),.zip() - Windowing:
.windows(),.chunks()
Prefer For Loops For
- Early exits:
break,continue,return - Side effects: logging, I/O
- When readability matters more than chaining
Anti-Patterns
// BAD - premature collect
let doubled: Vec<_> = items.iter().map(|x| x * 2).collect();
process(doubled.into_iter());
// GOOD - pass iterator directly
process(items.iter().map(|x| x * 2));
// BAD - .fold() for summing
items.iter().fold(0, |acc, x| acc + x);
// GOOD - .sum() is optimized
let total: i32 = items.iter().sum();Iterators are lazy. Nothing happens until you consume them with .collect(), .sum(), .for_each(), etc.
Error Mapping
Use inspect_err for logging and map_err for transforming:
result
.inspect_err(|e| tracing::error!("operation failed: {e}"))
.map_err(|e| AppError::from(e))?;Edition 2024 Awareness
Reserved Keyword: gen
Rust 2024 reserves gen as a keyword (for future generator support). Code using gen as an identifier must use the raw identifier escape:
// BAD (edition 2024) -- gen is a reserved keyword
let gen = 42;
fn gen() {}
// GOOD -- use raw identifier
let r#gen = 42;
fn r#gen() {}
// BETTER -- just rename to avoid confusion
let generation = 42;
fn generate() {}This affects variable names, function names, module names, and any other identifiers. Prefer renaming over r#gen for readability.
Never Type Fallback
In edition 2024, the ! (never) type falls back to ! instead of (). This affects diverging expressions in type inference:
// This compiles in edition 2021 (! falls back to ())
// May behave differently in edition 2024
let value = if condition {
42
} else {
panic!("unreachable")
// In 2021: inferred as () fallback
// In 2024: inferred as ! (never type)
};In practice, most well-typed code is unaffected. Watch for cases where diverging branches interact with trait resolution or match exhaustiveness.
if let Temporary Scope
Temporaries in if let conditions are now dropped at the end of the if let expression (not the enclosing block). This can affect code holding locks or references through temporaries:
// Edition 2024: temporary from get_mutex().lock() dropped earlier
if let Some(val) = get_mutex().lock().unwrap().get("key") {
// In edition 2024, the MutexGuard may already be dropped here
// if the temporary is not bound to a variable
}
// GOOD -- bind the guard explicitly
let guard = get_mutex().lock().unwrap();
if let Some(val) = guard.get("key") {
// guard lives until end of scope
}Import Ordering
Standard order: std -> external crates -> workspace crates -> super::/crate::
// std
use std::sync::Arc;
// external crates
use chrono::Utc;
use serde::{Deserialize, Serialize};
// workspace crates
use shared_types::Config;
// crate/super
use super::schema::Context;
use crate::models::Event;Configure rustfmt.toml for automatic enforcement:
reorder_imports = true
imports_granularity = "Crate"
group_imports = "StdExternalCrate"Drop Guards
RAII bound to a scoped local lets you restore state, release a flag, or run cleanup at scope exit, including on panic. The pattern is a small struct that implements Drop; the side effect lives in the destructor.
fn with_flag(flag: &AtomicBool, f: impl FnOnce()) {
flag.store(true, Ordering::Release);
struct Guard<'a>(&'a AtomicBool);
impl Drop for Guard<'_> {
fn drop(&mut self) { self.0.store(false, Ordering::Release); }
}
let _guard = Guard(flag); // bound to a name; lives until scope ends
f();
}The bug that catches everyone: let _ = Guard(flag) is NOT the same as let _guard = Guard(flag). The bare _ pattern is a wildcard, not a binding — the temporary is dropped immediately at the ;, before f() runs. A leading-underscore name (_guard) is a real binding and extends the lifetime to the end of the enclosing block.
let _ = Guard(flag); // BAD: Drop runs here, before f()
f();
let _guard = Guard(flag); // GOOD: Drop runs at end of scope
f();State-restore variants use mem::replace (or mem::take) to snapshot the prior value into the guard, then restore on drop:
let prev = mem::replace(&mut *cell, new_value);
struct Restore<'a, T>(&'a mut T, Option<T>);
impl<T> Drop for Restore<'_, T> {
fn drop(&mut self) { *self.0 = self.1.take().unwrap(); }
}
let _g = Restore(&mut *cell, Some(prev));For ad-hoc cleanup, the scopeguard crate's defer! macro packages the same pattern as a one-liner. Caveat: panic = "abort" skips destructors entirely and there is no unwind for std::panic::catch_unwind to intercept — the process terminates immediately. Under abort, neither drop guards nor catch_unwind will run cleanup. The only options are (a) build with the default panic = "unwind" so guards and catch_unwind work, or (b) restructure so cleanup happens on the success path before any panic-producing call. Crates that ship as cdylib / staticlib cannot dictate the consumer's panic strategy and should document this hazard explicitly.
Extension Traits
The orphan rule forbids impl ForeignTrait for ForeignType. The workaround is to define a new trait in your crate and blanket-impl it for the bound you want to extend:
pub trait MyExt {
fn my_method(&self) -> usize;
}
impl<T: AsRef<str>> MyExt for T {
fn my_method(&self) -> usize { self.as_ref().len() }
}Real examples: itertools::Itertools extends Iterator, futures::TryStreamExt extends TryStream, tower::ServiceExt extends Service. Splitting "core trait" from "ergonomic helpers" lets the helper trait evolve without forcing major bumps of the core.
When to reach for an extension trait: the type is foreign, the trait you'd want to impl is also foreign, and you want method-call syntax (x.my_method()) rather than a free function (my_method(x)). When NOT to reach for one: when you own the type — just write impl Type directly, no trait import required at every call site.
Pitfalls:
- Method names that shadow popular inherent methods (
len,iter,clone,into) on the implementing type cause call-site ambiguity that's hard to diagnose. - Add
#[doc(alias = "base_method")]to make the extension method discoverable when users search for the base trait's name. - If the trait is meant to be call-site-only and not implemented by downstream crates, seal it (
pub trait Ext: sealed::Sealed); otherwise adding a method becomes a breaking change.
Index Pointers
Storing data once in a Vec / Slab / arena and threading usize (or u32) indices through derived structures side-steps the borrow checker without unsafe. Real-world examples: petgraph stores nodes and edges as parallel Vecs with u32 endpoint indices; indexmap keeps keys in a Vec and stores positions in its hashmap; ECS world-state crates do the same. Cycles in the data are now expressible without Rc/Arc and refcount overhead.
use slotmap::{DefaultKey, SlotMap};
struct Graph {
nodes: SlotMap<DefaultKey, Node>,
edges: Vec<(DefaultKey, DefaultKey)>, // generational keys
}
impl Graph {
fn add_node(&mut self, n: Node) -> DefaultKey { self.nodes.insert(n) }
fn neighbors(&self, k: DefaultKey) -> impl Iterator<Item = DefaultKey> + '_ {
self.edges.iter().filter_map(move |&(a, b)| {
if a == k { Some(b) } else if b == k { Some(a) } else { None }
})
}
}Trade-offs:
- Lookup is O(1) but cache-cold compared to direct
&Tdereferences. Index pointers are not free. - No compile-time check that the index is valid. A stale
usizepanics at lookup time, or silently returns the wrong entry if the slot was reused. Vec::swap_remove(i)invalidates the indexlen-1(it moves into sloti). Any derived structure still holding that old index is now wrong. Either fix up every derived structure after the swap, or useVec::removeand accept O(n).- Mixing indices from different containers (a
Vec<Node>index used to look up in aVec<Edge>) is undetectable at the type level. Newtype each:struct NodeIdx(u32);,struct EdgeIdx(u32);. - For containers that delete and reuse slots, use generational indices:
slotmap::DefaultKeyandpetgraph::graph::NodeIndexcarry a generation that invalidates stale references at lookup time.
Crate Preludes
Curate a pub mod prelude that re-exports the items used in 80% of call sites, so users can write use somecrate::prelude::*; once at the top of a file. Preludes pair especially well with extension traits, whose methods are invisible-until-imported — diesel's prelude is what makes posts.filter(...).limit(5).load(&conn) compile without naming every helper trait.
pub mod prelude {
pub use crate::{Pool, Connection, Query};
pub use crate::traits::{Executor, Queryable, Loadable};
pub use crate::Result; // crate-specific Result alias
}What belongs in a prelude:
- Core traits users almost always need (extension traits, the crate's error trait).
- A
Resulttype alias if you have one. - Most-used types (connection handles, builders, the primary entry-point struct).
What does NOT belong:
- Internal types that escaped
puband shouldn't have. - Items that shadow the std prelude without a clear reason (
Result,Option,Box,Iterator).anyhow::Resultshadows deliberately; most don't. - Deprecated items — glob users get warnings they didn't opt into.
SemVer note: per RFC 1105, adding a trait to a published prelude is a minor breaking change because method-resolution ambiguity can break user code at the call site (even though glob imports have lower precedence than named imports, traits in scope affect inherent-method resolution). Reserve prelude additions for major versions when feasible. Tokio's and Diesel's preludes are the gold standard: small, mostly traits, stable across minor versions.
Review-Side Companion
For the reviewer-facing checklist of these patterns — [FILE:LINE] checks for let _ = guard typos, swap_remove aliasing, extension-trait overuse, prelude bloat, and related SemVer hazards — see ../../rust-code-review/references/patterns-in-the-wild.md.
Documentation
Comments vs Doc Comments
| Purpose | // comment | /// doc / //! crate doc |
|---|---|---|
| Describe why | Yes -- explains reasoning | No |
| Describe API | No | Yes -- public interfaces, usage |
| Maintainable | Gets stale, not compiled | Tied to code, appears in cargo doc |
| Testable | No | Yes -- doc examples run with cargo test |
| Visibility | Local to source | Exported to users and tools |
When to Use // Comments
Use when something can't be expressed clearly in code:
// SAFETY: ptr is guaranteed non-null and aligned by caller contract
unsafe { std::ptr::copy_nonoverlapping(src, dst, len); }
// PERF: Caching root cert store avoids repeated OS calls on macOS
// See ADR-12: TLS startup latency
let tls_store = cached_root_store();Good Comments
- Safety invariants:
// SAFETY: ... - Performance reasoning:
// PERF: ... - Design context:
// CONTEXT: See ADR-42 for rationale - Workarounds:
// WORKAROUND: upstream bug #123
Bad Comments
// BAD - restates the obvious
counter += 1; // increment counter by 1
// BAD - wall of text that should be a doc comment or ADR
// This function was originally written in 2023 for the legacy API...
// [20 more lines of history]Replace Comments with Code
If you're writing a long comment explaining "what" or "how", refactor instead:
// DON'T
fn process_request(req: Request) -> Result<(), Error> {
// validate headers, then decode body, then authorize, then dispatch
// ...100 lines...
}
// DO
fn process_request(req: Request) -> Result<(), Error> {
validate_headers(&req)?;
let body = decode_body(&req)?;
authorize(&body)?;
dispatch(body)
}Structure and naming replace commentary. Tests serve as living documentation.
TODO Discipline
Don't leave orphan TODOs. Link to a tracked issue:
// TODO(#42): Remove workaround after upstream fix landsWhen to Use /// Doc Comments
Document all public items: functions, structs, traits, enums, constants.
/// Loads a user profile from disk.
///
/// # Errors
///
/// Returns [`AppError::NotFound`] if the file is missing.
/// Returns [`AppError::InvalidFormat`] if the content is not valid JSON.
///
/// # Examples
///
/// ```rust
/// # use my_crate::load_user;
/// let user = load_user("profiles/alice.json")?;
/// assert_eq!(user.name, "Alice");
/// # Ok::<(), my_crate::AppError>(())
/// ```
pub fn load_user(path: &str) -> Result<User, AppError> { /* ... */ }Required Sections
- Purpose -- what the item does (first line)
- `# Examples` -- runnable code showing usage
- `# Errors` -- when it returns
Err(for Result-returning functions) - `# Panics` -- when it can panic (if applicable)
- `# Safety` -- invariants for
unsafefunctions
Doc Test Tips
- Hide setup lines with
#prefix - Examples run with
cargo test(but NOTcargo nextest-- runcargo test --docseparately) - Use
compile_failattribute for wrong-usage examples - Use
no_runfor side-effect examples (network, file I/O)
Module-Level Docs with //!
Place at the top of lib.rs or mod.rs:
//! HTTP client with retry and circuit-breaker support.
//!
//! This module provides a resilient HTTP client that wraps `reqwest`
//! with automatic retries and circuit-breaker patterns.
//!
//! # Examples
//!
//! ```rust
//! let client = http::Client::builder().retries(3).build();
//! let response = client.get("https://api.example.com").await?;
//! ```Custom Error Messages with #[diagnostic::on_unimplemented]
Since Rust 1.78, you can provide custom compiler error messages when a trait is not implemented. This dramatically improves developer experience for library traits:
#[diagnostic::on_unimplemented(
message = "`{Self}` is not a valid handler function",
label = "this type does not implement `Handler`",
note = "Handler functions must accept a `Request` and return `impl IntoResponse`"
)]
trait Handler {
fn call(&self, req: Request) -> Response;
}When someone tries to use a type that doesn't implement Handler, they see your custom message instead of the generic "trait bound not satisfied" error.
Use this for:
- Public library traits where users frequently hit confusing errors
- Trait bounds with non-obvious requirements (e.g., axum handlers, tower services)
- Domain-specific traits where the fix is not obvious from the trait name
Documentation Lints
Enable for library crates:
// In lib.rs
#![deny(missing_docs)]| Lint | Purpose |
|---|---|
missing_docs | Warn on undocumented public items |
broken_intra_doc_links | Detect broken [links] in doc comments |
missing_panics_doc | Require # Panics section when function can panic |
missing_errors_doc | Require # Errors for Result-returning functions |
missing_safety_doc | Require # Safety for unsafe public functions |
empty_docs (clippy) | Prevent empty doc comments that bypass missing_docs |
Run cargo doc --open to preview your documentation output.
Checklist
- [ ] All public items have
///doc comments - [ ]
//!at top of lib.rs/mod.rs explaining purpose - [ ]
# Exampleswith runnable code on key functions - [ ]
# Errorsand# Panicssections where applicable - [ ]
// SAFETY:comments on allunsafeblocks - [ ] No stale comments that describe old behavior
- [ ] Every
TODOlinks to a tracked issue - [ ]
#![deny(missing_docs)]enabled for library crates
Ecosystem Patterns
Evaluating Crates
Before adding a dependency, assess it across these dimensions:
| Signal | What to Check |
|---|---|
| Downloads | crates.io recent download trend, not just total |
| Maintenance | Commit recency, open issue response time, release cadence |
| Documentation | Docs.rs quality, examples, module-level guides |
| Dependencies | cargo tree -i <crate> -- how much does it pull in? |
| Compile time | Test with cargo build --timings before committing |
| Soundness | Check for unsafe usage, look for past CVEs |
Prefer crates that are narrowly scoped, have few transitive dependencies, and gate optional features behind Cargo features.
Error Handling: anyhow vs thiserror vs eyre
| Crate | Use When | Returns |
|---|---|---|
thiserror | Library code with structured error types | enum MyError { ... } |
anyhow | Application/binary code, rapid prototyping | anyhow::Result<T> |
eyre | Application code needing custom reporters | eyre::Result<T> |
Decision framework:
- Writing a library others depend on? Use
thiserrorso callers can match on variants. - Writing a binary or CLI? Use
anyhowfor ergonomic context chaining. - Need custom error formatting (color, structured logs)? Use
eyrewith a custom handler. - Never use
anyhowin library public APIs -- it erases error types that downstream callers need.
// Library: structured errors with thiserror
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
#[error("key not found: {key}")]
NotFound { key: String },
#[error("connection failed")]
Connection(#[from] std::io::Error),
}
// Binary: contextual errors with anyhow
use anyhow::{Context, Result};
fn main() -> Result<()> {
let config = load_config()
.context("failed to load configuration")?;
run(config)
}Common Design Patterns
Newtype
Wrap a primitive to add meaning and prevent mixing up arguments of the same underlying type.
struct UserId(u64);
struct OrderId(u64);
// Can't accidentally pass UserId where OrderId is expectedAlso used to work around the orphan rule when implementing foreign traits on foreign types.
Type State
Encode workflow stages in generic parameters so invalid transitions are compile errors. See type-state-pattern.md.
Sealed Traits
Prevent external implementations while keeping the trait usable. See api-design.md for the pattern.
Builder
Construct complex types step-by-step with validation at build time. Use for types with more than 3-4 optional fields. See api-design.md.
Orphan Rule Strategies
You cannot implement a foreign trait for a foreign type. Workarounds:
1. Newtype wrapper -- wrap the foreign type and implement the trait on the wrapper 2. Extension trait -- define a new trait with the methods you need, blanket-implement it 3. Upstream PR -- contribute the implementation to the crate that owns the type or trait
// Extension trait pattern
trait IteratorExt: Iterator {
fn sum_by<F, S>(self, f: F) -> S
where
F: FnMut(Self::Item) -> S,
S: std::iter::Sum;
}
impl<I: Iterator> IteratorExt for I {
fn sum_by<F, S>(self, f: F) -> S
where
F: FnMut(Self::Item) -> S,
S: std::iter::Sum,
{
self.map(f).sum()
}
}When to Vendor vs Depend
Prefer a dependency when:
- The crate is well-maintained and narrowly scoped
- You'd have to replicate significant correctness-sensitive logic
- Security-sensitive code (crypto, parsing) -- defer to audited crates
Prefer vendoring or writing it yourself when:
- The crate pulls in heavy transitive dependencies for a small feature
- You need only a tiny fraction of the crate's functionality
- The crate is unmaintained or has no recent releases
- Build times are a critical constraint
Use cargo-udeps to detect unused dependencies and cargo-deny to audit licenses and vulnerabilities.
Edition Migration
Rust editions (2015, 2018, 2021, 2024) are opt-in per crate via Cargo.toml. Different crates in a dependency tree can use different editions and interoperate.
Migration process: 1. Run cargo fix --edition to apply automated fixes 2. Update edition = "2024" in Cargo.toml 3. Run cargo clippy and fix new warnings 4. Review edition-specific changes (see coding-idioms.md for 2024 specifics)
Avoid skipping editions -- migrate incrementally. The automated tooling handles most changes, but review the edition guide for semantic differences.
Essential Tools
| Tool | Purpose |
|---|---|
cargo-deny | Lint dependency graph for licenses, vulnerabilities, duplicates |
cargo-udeps | Find unused dependencies |
cargo-outdated | Detect available updates including major version bumps |
cargo-expand | Inspect macro expansion output |
cargo-hack | Test all feature combinations |
cargo-llvm-lines | Find monomorphization-heavy code increasing compile time |
Staying Current
- This Week in Rust (this-week-in-rust.org) -- weekly ecosystem digest
- Rust Blog (blog.rust-lang.org) -- release announcements and feature highlights
- Rust RFCs (github.com/rust-lang/rfcs) -- upcoming language changes
- Clippy -- enable it always; it surfaces new language features and idioms
- Edition Guide (doc.rust-lang.org/edition-guide) -- what changed per edition
- caniuse.rs -- look up when a specific feature landed on stable
Generics and Dispatch
Static Dispatch: impl Trait / <T: Trait>
Generics are monomorphized at compile time -- the compiler generates specialized code for each concrete type. Zero runtime cost.
Use When
- Performance-critical code (tight loops, hot paths)
- Types are known at compile time
- You want inlining and optimization
// Generic function -- compiler generates specialized versions
fn process<T: Display>(item: T) {
println!("{item}");
}
// Equivalent modern syntax
fn process(item: impl Display) {
println!("{item}");
}Dynamic Dispatch: dyn Trait
Uses a vtable (virtual function table) for runtime polymorphism. Incurs indirection cost per call.
Use When
- Heterogeneous collections (different types in one
Vec) - Plugin architectures with runtime-loaded components
- Abstracting internals behind a stable public interface
- Binary size matters more than call performance
// Different types in one collection
fn greet_all(animals: &[Box<dyn Animal>]) {
for animal in animals {
println!("{}", animal.greet());
}
}Trade-Off Table
| Aspect | Static (impl Trait) | Dynamic (dyn Trait) |
|---|---|---|
| Performance | Faster, inlined | Slower, vtable indirection |
| Compile time | Slower (monomorphization) | Faster (shared code) |
| Binary size | Larger (per-type codegen) | Smaller |
| Flexibility | One type at a time | Mix types in collections |
| Error messages | Clearer | Type erasure obscures errors |
Decision Guide
1. Start with generics. They are the default in Rust. 2. Switch to `dyn Trait` when you need runtime polymorphism or heterogeneous collections. 3. If unsure, use generics with trait bounds -- refactor to dyn only when flexibility outweighs speed.
Best Practices for Dynamic Dispatch
Pointer Choice
// Prefer &dyn Trait when you don't need ownership
fn log(writer: &dyn Write) { /* ... */ }
// Use Box<dyn Trait> when you need to own the value
fn create_handler() -> Box<dyn Handler> { /* ... */ }
// Use Arc<dyn Trait> for shared access across threads
fn register(service: Arc<dyn Service>) { /* ... */ }Don't Box Too Early
// DO - use generics when possible
struct Renderer<B: Backend> {
backend: B,
}
// DON'T - premature boxing reduces performance and flexibility
struct Renderer {
backend: Box<dyn Backend>,
}Box at API boundaries (public return types), not inside structs.
Object Safety Rules
You can only create dyn Trait from object-safe traits:
- No generic methods
- No
Self: Sizedbound - All methods use
&self,&mut self, orself
// Object-safe -- can use as dyn
trait Runnable {
fn run(&self);
}
// NOT object-safe -- generic method
trait Factory {
fn create<T>(&self) -> T;
}RPIT Lifetime Capture (Edition 2024)
In Rust 2024, -> impl Trait return types capture ALL in-scope generic parameters and lifetimes by default. Previously (edition 2021), hidden types only captured the generic parameters explicitly mentioned in the opaque type.
What Changed
// Edition 2021: 'a is NOT captured, return type outlives 'a
fn foo<'a>(x: &'a str) -> impl Display {
x.len() // returns usize, no lifetime dependency
}
// Edition 2024: 'a IS captured by default, return type borrows 'a
fn foo<'a>(x: &'a str) -> impl Display {
x.len() // still returns usize, but type signature now captures 'a
}Precise Capturing with + use<>
When you need to opt out of capturing a lifetime or generic, use the + use<> syntax:
// Only capture T, not the lifetime 'a
fn extract<'a, T: Display>(data: &'a [T]) -> impl Display + use<T> {
data.len()
}
// Capture nothing -- equivalent to edition 2021 behavior
fn compute<'a>(x: &'a str) -> impl Display + use<> {
x.len()
}Use + use<> when the return type genuinely does not depend on a lifetime, and callers need the returned value to outlive the borrow.
Async Functions in Traits
Since Rust 1.75, async fn works directly in traits without the async-trait crate for many use cases:
// GOOD (Rust 1.75+) -- native async fn in trait
trait DataStore {
async fn get(&self, key: &str) -> Option<String>;
async fn put(&self, key: &str, value: &str) -> Result<(), StoreError>;
}
// BAD -- unnecessary async-trait dependency
#[async_trait::async_trait]
trait DataStore {
async fn get(&self, key: &str) -> Option<String>;
async fn put(&self, key: &str, value: &str) -> Result<(), StoreError>;
}When You Still Need async-trait
- `dyn Trait` dispatch -- native async fn in traits is not yet object-safe. If you need
Box<dyn DataStore>, you still needasync-traitor manual boxing. - Older MSRV -- if your minimum supported Rust version is below 1.75.
For most application code with static dispatch, drop async-trait and use native syntax.
Patterns
Accept Generic, Return Concrete
Public APIs often accept generics for flexibility but return concrete types:
pub fn parse(input: impl AsRef<str>) -> Result<Config, ParseError> {
let s = input.as_ref();
// ...
}Trait Bounds on Impl Blocks
Constrain methods to specific trait implementations:
struct Wrapper<T>(T);
impl<T: Display> Wrapper<T> {
fn show(&self) {
println!("{}", self.0);
}
}
impl<T: Display + Debug> Wrapper<T> {
fn debug_show(&self) {
println!("{:?}", self.0);
}
}Where Clauses for Readability
// Hard to read
fn process<T: Clone + Debug + Send + Sync + 'static>(item: T) { /* ... */ }
// Clearer
fn process<T>(item: T)
where
T: Clone + Debug + Send + Sync + 'static,
{
// ...
}Variance Rules for Generics
Variance determines how subtyping relationships carry through generic types.
| Variance | Rule | Example |
|---|---|---|
| Covariant | If 'a: 'b, then T<'a>: T<'b> | &'a T, Vec<T>, Box<T> |
| Contravariant | If 'a: 'b, then T<'b>: T<'a> | fn(T) (in argument position) |
| Invariant | No subtyping relationship | &'a mut T, Cell<T>, UnsafeCell<T> |
Practical impact: &'a mut T is invariant over T, meaning you cannot coerce &mut Vec<&'static str> to &mut Vec<&'a str>. This prevents unsoundness where a shorter-lived reference could be inserted through the mutable alias.
Prefer &T (covariant) over &mut T (invariant) in generic contexts when mutation is not needed -- it gives callers more flexibility with lifetimes.
Trait Object Cost Analysis
Dynamic dispatch via dyn Trait introduces two costs:
1. Vtable indirection -- each method call goes through a pointer lookup instead of a direct call. Prevents inlining and limits compiler optimizations. 2. Loss of monomorphization -- the compiler cannot specialize code per concrete type, eliminating opportunities for constant folding and layout optimization.
The overhead per call is typically 1-3 ns (vtable pointer chase). Avoid dyn Trait in tight loops or hot paths. Use it freely at architectural boundaries where call frequency is low.
Object Safety Rules
A trait is object-safe (usable as dyn Trait) only when all methods satisfy:
- No generic type parameters on methods (generic params on the trait itself are fine)
- No use of `Self` as a concrete type in arguments or return position
- No `Self: Sized` bound on the trait itself (individual methods can have it)
- Receivers must be dispatchable:
&self,&mut self,self,Box<Self>,Arc<Self>,Pin<&Self>, etc.
// NOT object-safe -- generic method prevents vtable construction
trait Parser {
fn parse<T: FromStr>(&self, input: &str) -> T;
}
// Fix: exempt the generic method, keep the trait object-safe
trait Searchable {
fn search(&self, query: &str) -> Vec<String>;
fn search_typed<T>(&self, query: &str) -> Vec<T> where Self: Sized;
// ^^ only callable on concrete types; dyn Searchable still works for search()
}Prefer keeping traits object-safe. Add where Self: Sized to convenience methods that break object safety rather than sacrificing the whole trait.
Complete Dispatch Decision Tree
Do you need different concrete types in the same collection or behind one pointer?
YES -> dyn Trait (dynamic dispatch)
-> Need ownership? Box<dyn Trait>
-> Borrowed only? &dyn Trait
-> Shared across threads? Arc<dyn Trait>
NO -> Do callers need to name the concrete return type?
YES -> Generic <T: Trait> (full monomorphization)
NO -> impl Trait (opaque type, still static dispatch)`impl Trait` vs `dyn Trait` vs `T: Trait` summary:
| Feature | T: Trait | impl Trait | dyn Trait |
|---|---|---|---|
| Dispatch | Static | Static | Dynamic |
| Caller picks type | Yes | Arg: yes, Return: no | No (type-erased) |
Turbofish (::<>) | Yes | No | N/A |
| Multiple types in collection | No | No | Yes |
| Binary size impact | Larger (codegen per type) | Larger | Smaller |
| Use in trait definitions | Yes | Limited | Yes |
Blanket Implementation Patterns
Blanket impls provide automatic trait implementations for broad categories of types. Use them to reduce boilerplate, but be aware of their downstream impact.
// Common: forward trait through references
impl<T: MyTrait> MyTrait for &T {
fn method(&self) { (**self).method() }
}
// Common: forward through smart pointers
impl<T: MyTrait + ?Sized> MyTrait for Box<T> {
fn method(&self) { (**self).method() }
}
// Powerful but constraining: implement for all types meeting a bound
impl<T: Display> Loggable for T {
fn log(&self) { println!("{self}"); }
}Impact on downstream users: a blanket impl prevents anyone from writing a more specific impl for the same trait. Once impl<T: Display> Loggable for T exists, no one can write impl Loggable for MyType even if MyType: Display. Plan blanket impls carefully -- adding one later is a breaking change due to coherence rules.
Performance
Golden Rule
Don't guess, measure.
Rust code is fast by default. Optimize only after finding bottlenecks with real profiling data.
First Steps
1. Build with `--release` -- debug builds lack optimizations. Most "Rust is slow" complaints come from debug builds. 2. Run `cargo clippy -- -D clippy::perf` -- catches common performance anti-patterns. 3. Benchmark before and after -- use cargo bench to verify improvements (>5% = worthwhile). 4. Profile with flamegraphs -- cargo flamegraph or samply (macOS) to find real hotspots.
Profiling Tools
cargo bench
Built-in micro-benchmarking. Write scenarios and compare:
cargo benchcargo flamegraph
Visualize CPU time per function:
cargo install flamegraph
cargo flamegraph --release # always profile with --releaseReading flamegraphs:
- Width = time spent (wider = more CPU time)
- Y-axis = stack depth (main at bottom, called functions stacked up)
- Color = random (not meaningful)
- Thick stacks = heavy CPU usage, investigate these
samply (macOS alternative)
Better developer experience on macOS:
cargo install samply
samply record cargo run --releaseAvoid Redundant Cloning
Clone at the last possible moment, if at all:
// BAD - clone in loop
for item in &items {
process(item.clone()); // clone per iteration
}
// GOOD - borrow
for item in &items {
process(item); // just borrow
}When to Pass Ownership
- API requires owned data
- Sending data to another thread (
Arc::cloneis cheap) - Operator overloads that consume
self - Modeling business logic transitions (
Validate::try_from(raw_input))
When NOT to Pass Ownership
- Function only reads the data: use
&Tor&[T] - Iteration: use
&some_vecor.iter() - Mutation: use
&mut T
Cow for Maybe-Owned Data
use std::borrow::Cow;
fn normalize(input: Cow<'_, str>) -> Cow<'_, str> {
if input.contains('\t') {
Cow::Owned(input.replace('\t', " "))
} else {
input // no allocation needed
}
}Stack vs Heap
Keep on the Stack
- Small types: primitives,
Copytypes,usize,bool - Types returned by value that are cheap to copy
Move to the Heap
- Recursive data structures:
Box<Node>,Box<[Node; 8]> - Large buffers (>512 bytes)
- Types behind trait objects:
Box<dyn Trait>
// BAD - allocates 64KB on stack then moves to heap
let buffer: Box<[u8; 65536]> = Box::new([0u8; 65536]);
// GOOD - allocates directly on heap
let buffer: Box<[u8]> = vec![0u8; 65536].into_boxed_slice();Be Cautious With
#[inline]-- only use when benchmarks prove benefit. Rust already inlines well.- Large stack arrays -- consider
smallvecfor arrays that might grow. - Large stack-allocated arrays (
let buf: [u8; 65536]) -- they live on the stack and can overflow it. UseBox<[T; N]>orVec<T>for large data.
Iterator Optimization
Iterators compile to tight loops (zero-cost abstractions):
// GOOD - compiler optimizes this into a single loop
let total: i32 = items.iter()
.filter(|x| x.is_valid())
.map(|x| x.value())
.sum();IntoIterator for Box<[T]> (Edition 2024)
Rust 2024 adds IntoIterator for Box<[T]>, so boxed slices can be iterated directly:
// Previously required converting to Vec first
let boxed: Box<[i32]> = vec![1, 2, 3].into_boxed_slice();
// BAD (pre-2024) -- convert to Vec to iterate by value
let items: Vec<i32> = boxed.into_vec();
for item in items { /* ... */ }
// GOOD (edition 2024) -- iterate directly
let boxed: Box<[i32]> = vec![1, 2, 3].into_boxed_slice();
for item in boxed { /* ... */ }Avoid Intermediate Collections
// BAD - allocates a Vec just to iterate again
let valid: Vec<_> = items.iter().filter(|x| x.is_valid()).collect();
process(valid.into_iter());
// GOOD - pass the iterator (fn process(iter: impl Iterator<Item = &T>))
process(items.iter().filter(|x| x.is_valid()));Prefer .sum() Over .fold()
.sum() is specialized and the compiler can optimize it better:
// DO
let total: i32 = values.iter().sum();
// DON'T (unless you need a different initial value or accumulator)
let total = values.iter().fold(0, |acc, x| acc + x);Use Capacity Hints
// DO - pre-allocate when size is known
let mut results = Vec::with_capacity(items.len());
// DON'T - grow incrementally
let mut results = Vec::new();String Performance
// BAD in hot path - format! allocates every call
for item in items {
log(&format!("processing {}", item.id));
}
// GOOD - reuse buffer
let mut buf = String::with_capacity(64);
for item in items {
buf.clear();
use std::fmt::Write;
write!(&mut buf, "processing {}", item.id).unwrap();
log(&buf);
}Monomorphization Budgets
Generic functions are compiled separately for each concrete instantiation. A large generic body instantiated for 20 types becomes 20 copies in the binary. Costs compound across three dimensions:
- Compile time -- significant in workspaces with many generic-heavy
crates; each downstream user pays again for their own instantiations.
- Binary size -- megabytes of duplicated code from a single popular
generic.
- Instruction-cache pressure -- the CPU's L1i is small (typically
32KB). Bloated code paths evict hot inner loops.
Mitigations, in order of preference:
- Extract type-independent inner functions. Push the
type-parameterized work to the boundary and dispatch into a non-generic body compiled once:
pub fn process<T: Serialize>(items: &[T]) -> Vec<u8> {
let bytes: Vec<Vec<u8>> = items.iter().map(serialize_one).collect();
process_bytes(bytes) // non-generic, compiled once
}
fn process_bytes(bytes: Vec<Vec<u8>>) -> Vec<u8> { /* ... */ }- Switch internal generics to `dyn Trait` for binary internals where
peak inlining is not required. The vtable indirection is cheap compared to icache misses from duplicated generic bodies.
- Bound generics in libraries with
impl Traitin argument position
to keep the generic surface small and let callers' compilers monomorphize only at the public boundary.
Diagnose with cargo llvm-lines to see which generic functions emit the most LLVM IR lines per crate.
Cache-Line Alignment and False Sharing
When two CPUs access different values that share a cache line, the cache coherency protocol serializes the accesses. Two logically independent atomic operations become a sequential pair as the line ping-pongs between cores. The symptom is scaling tests that show worse-than-linear speedup -- or even slowdown -- as you add cores.
Fixes:
#[repr(align(64))]on per-thread counters or hot atomic structs.- On Apple Silicon (M1+) and some server CPUs, cache lines are 128 bytes
-- prefer align(128) if you target those platforms.
crossbeam::utils::CachePadded<T>as a portable wrapper that picks a
sensible alignment per target.
#[repr(align(64))]
struct PerThreadCounter(AtomicU64);
let counters: Vec<PerThreadCounter> = (0..num_cpus::get())
.map(|_| PerThreadCounter(AtomicU64::new(0)))
.collect();Cross-ref: see ../../rust-code-review/references/types-layout.md for wide-pointer and repr(align) review checks, and ../../rust-code-review/references/concurrency-primitives.md for atomic ordering and contention patterns.
Criterion Benchmarking Discipline
criterion is the de facto Rust benchmark harness; it replaces the unstable nightly #[bench] attribute.
- Statistical confidence. Criterion runs each benchmark many times,
reports mean plus standard deviation, and compares against prior baselines with a confidence interval. Single-shot timings are noise -- variance from CPU frequency scaling, ASLR, and kernel scheduling dwarfs sub-microsecond differences.
- Baseline persistence.
cargo bench -- --save-baseline mainstores
a baseline named main. On a feature branch, cargo bench -- --baseline main compares against it. CI integrates this for regression detection.
- Regression detection in CI. Fail the build on greater-than-X%
regression (typically 5-10%) on hot paths. Treat performance regressions as test failures, not warnings.
- `black_box` discipline.
criterion::black_boxprevents the
optimizer from constant-folding the benchmark away. Use black_box(input.as_ptr()) for pointer-flavored inputs -- the optimizer cannot reason about pointer-derived state. black_box(&input) is sometimes insufficient because the optimizer can see through & and assume the value is not mutated.
- `--profile bench`. Builds benchmarks with release optimizations
plus debug symbols so flamegraph correlation lines up with source.
- Keep I/O outside the measurement loop. File and network setup
belongs in bench_function's setup closure (or iter_batched's routine closure), not the measurement closure. Otherwise you are benchmarking I/O.
use criterion::{black_box, criterion_group, BatchSize, Criterion};
fn bench_parse(c: &mut Criterion) {
c.bench_function("parse_url", |b| {
b.iter_batched(
|| generate_input(), // setup, untimed
|input| black_box(parse(input.as_ptr())), // measured
BatchSize::SmallInput,
);
});
}
criterion_group!(benches, bench_parse);Compile-Time as a Performance Concern
At workspace scale (10+ crates, 100k+ LOC), compile time is a real developer-productivity tax. Treat it as a perf budget:
- Split feature flags so dev iteration disables expensive deps.
Procedural macros (syn with features = ["full"], serde_derive, tokio-macros) are the biggest cost; gate non-essential ones behind #[cfg(feature = "...")].
- `[profile.dev.package.<slow-dep>]` with `opt-level = 3` makes slow
deps build once in release and stay cached across dev rebuilds. Useful for crypto, regex, image-codec, and other CPU-bound dependencies that are otherwise painfully slow in debug.
- `cargo-bloat`, `cargo-llvm-lines`, `cargo-show-asm` for diagnosing
generic-instantiation cost. cargo llvm-lines ranks functions by IR output volume -- the worst offenders are usually generics worth extracting into non-generic inner functions (see Monomorphization Budgets above).
- `RUSTFLAGS="-Zthreads=8"` on nightly enables the parallel rustc
frontend, which can halve clean-build times on multi-core machines.
Pointer Types
Thread Safety: Send and Sync
Rust tracks pointer safety through two marker traits:
- `Send` -- data can move across threads
- `Sync` -- data can be referenced from multiple threads simultaneously
A pointer is thread-safe only if the data behind it is.
Quick Reference
| Type | Description | Send + Sync | Use When |
|---|---|---|---|
&T | Shared reference | Yes (if T: Sync) | Multiple readers, no mutation |
&mut T | Exclusive mutable reference | Send (if T: Send), Sync (if T: Sync) | Single writer |
Box<T> | Heap-allocated, single owner | Send (if T: Send), Sync (if T: Sync) | Recursive types, large data, trait objects |
Rc<T> | Reference counted, single thread | Neither | Multiple owners, same thread |
Arc<T> | Atomic reference counted | Yes (if T: Send + Sync) | Multiple owners, across threads |
Cell<T> | Interior mutability, Copy types | Not Sync | Shared mutable state, single thread |
RefCell<T> | Interior mutability, runtime checks | Not Sync | Shared mutable state, single thread |
Mutex<T> | Thread-safe exclusive access | Yes (if T: Send) | Shared mutable state, across threads |
RwLock<T> | Thread-safe read-many/write-one | Send (if T: Send), Sync (if T: Send + Sync) | Read-heavy shared state, across threads |
OnceCell<T> | One-time init, single thread | Not Sync | Lazy initialization |
OnceLock<T> | One-time init, thread-safe | Yes | Static lazy values (replaces lazy_static!) |
LazyCell<T> | Deferred init with closure | Not Sync | Complex lazy init, single thread |
LazyLock<T> | Deferred init with closure, thread-safe | Yes | Complex static initialization |
*const T / *mut T | Raw pointers | Neither (manual) | FFI, raw memory |
When to Use Each
&T -- Shared Borrow
The most common type. Safe, no mutation, multiple readers:
fn print_len(s: &str) {
println!("{}", s.len());
}&mut T -- Exclusive Borrow
Single writer, enforced at compile time:
fn append_suffix(s: &mut String) {
s.push_str("_updated");
}Box<T> -- Heap Allocation
Single-owner heap data. Required for recursive types:
enum Tree<T> {
Leaf(T),
Branch(Box<Tree<T>>, Box<Tree<T>>),
}Rc<T> / Arc<T> -- Shared Ownership
Rc for single-threaded, Arc for multi-threaded:
// Multi-thread: Arc + Mutex for shared mutable state
let shared = Arc::new(Mutex::new(Vec::new()));
let clone = Arc::clone(&shared);Common mistake: using Arc<Mutex<T>> when data is single-threaded. Use Rc<RefCell<T>> instead.
Cell<T> / RefCell<T> -- Interior Mutability
Mutate data behind a shared reference. Cell for Copy types, RefCell for others:
use std::cell::Cell;
struct Counter {
count: Cell<u32>,
}
impl Counter {
fn increment(&self) { // note: &self, not &mut self
self.count.set(self.count.get() + 1);
}
}RefCell panics on double borrow at runtime. Prefer compile-time borrowing when possible.
Mutex<T> / RwLock<T> -- Thread-Safe Mutability
Mutex for exclusive access, RwLock when reads outnumber writes:
use std::sync::{Arc, Mutex};
let data = Arc::new(Mutex::new(HashMap::new()));
// In a thread:
let mut map = data.lock().unwrap();
map.insert("key", "value");
// Lock released when `map` dropsOnceLock<T> / LazyLock<T> -- One-Time Initialization
Replace lazy_static! and once_cell with standard library types. LazyLock (stable since 1.80) and OnceLock make third-party lazy initialization crates unnecessary:
use std::sync::OnceLock;
static CONFIG: OnceLock<Config> = OnceLock::new();
fn get_config() -> &'static Config {
CONFIG.get_or_init(|| load_config_from_disk())
}use std::sync::LazyLock;
static REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap()
});Migrating from lazy_static / once_cell
// BAD -- third-party dependency no longer needed
lazy_static::lazy_static! {
static ref CONFIG: Config = load_config();
}
// BAD -- once_cell also superseded
static CONFIG: once_cell::sync::Lazy<Config> = once_cell::sync::Lazy::new(|| load_config());
// GOOD -- standard library LazyLock
static CONFIG: LazyLock<Config> = LazyLock::new(|| load_config());For single-threaded contexts, use LazyCell / OnceCell (from std::cell) instead of their sync counterparts:
use std::cell::LazyCell;
// Thread-local lazy value -- no atomic overhead
let lazy_value = LazyCell::new(|| expensive_computation());Remove lazy_static and once_cell from Cargo.toml once all usages are migrated.
Decision Guide
Need heap allocation?
-> Single owner: Box<T>
-> Shared ownership, single thread: Rc<T>
-> Shared ownership, multi-thread: Arc<T>
Need interior mutability?
-> Copy type, single thread: Cell<T>
-> Non-Copy, single thread: RefCell<T>
-> Multi-thread, exclusive: Mutex<T>
-> Multi-thread, read-heavy: RwLock<T>
Need lazy initialization?
-> Single thread: OnceCell<T> / LazyCell<T>
-> Multi-thread / static: OnceLock<T> / LazyLock<T>Type State Pattern
What It Is
Type State Pattern encodes different states of a system as types, not runtime flags. The compiler enforces valid state transitions -- invalid operations become compile errors instead of runtime bugs.
PhantomData<State> is removed after compilation, so there is no runtime overhead.
Simple Example: Connection State
use std::marker::PhantomData;
struct Disconnected;
struct Connected;
struct Client<State> {
addr: String,
_state: PhantomData<State>,
}
impl Client<Disconnected> {
fn new(addr: &str) -> Self {
Client { addr: addr.to_string(), _state: PhantomData }
}
fn connect(self) -> Result<Client<Connected>, std::io::Error> {
// ... establish connection ...
Ok(Client { addr: self.addr, _state: PhantomData })
}
}
impl Client<Connected> {
fn send(&self, msg: &[u8]) -> Result<(), std::io::Error> {
// Only available when connected
Ok(())
}
}let client = Client::new("localhost:8080");
// client.send(b"hello"); // Won't compile -- not connected yet
let connected = client.connect()?;
connected.send(b"hello")?; // WorksBuilder with Required Fields
Force callers to set required fields before .build():
use std::marker::PhantomData;
struct Missing;
struct Set;
struct Builder<NameState, PortState> {
name: Option<String>,
port: Option<u16>,
_name: PhantomData<NameState>,
_port: PhantomData<PortState>,
}
impl Builder<Missing, Missing> {
fn new() -> Self {
Builder {
name: None, port: None,
_name: PhantomData, _port: PhantomData,
}
}
}
impl<P> Builder<Missing, P> {
fn name(self, name: impl Into<String>) -> Builder<Set, P> {
Builder {
name: Some(name.into()), port: self.port,
_name: PhantomData, _port: PhantomData,
}
}
}
impl<N> Builder<N, Missing> {
fn port(self, port: u16) -> Builder<N, Set> {
Builder {
name: self.name, port: Some(port),
_name: PhantomData, _port: PhantomData,
}
}
}
impl Builder<Set, Set> {
fn build(self) -> Server {
Server {
name: self.name.unwrap(),
port: self.port.unwrap(),
}
}
}// Valid -- both required fields set
let server = Builder::new().name("api").port(8080).build();
let server = Builder::new().port(8080).name("api").build(); // order doesn't matter
// Won't compile -- missing required field
// let server = Builder::new().name("api").build(); // Error: port not setWhen to Use
- Compile-time state safety -- prevent invalid operations entirely
- API constraints -- builders with required fields, protocol state machines
- Replace runtime booleans --
is_connected,is_authenticatedbecome type-level guarantees - Workflow pipelines -- validate -> authorize -> process
When to Avoid
- Trivial state -- simple enums are clearer for 2-3 states without complex transitions
- Runtime flexibility -- state determined by user input at runtime
- Complex generics -- if the type signature becomes harder to understand than the bug it prevents
- Not worth the verbosity -- pattern requires duplicating struct fields across state transitions
Downsides
- More verbose than runtime checks
- Complex type signatures with multiple state parameters
- PhantomData is not intuitive for Rust beginners
- Struct fields must be moved between states (some duplication)
Use when it saves bugs, increases safety, or simplifies logic -- not for cleverness.