Rust Idioms and Design Patterns
Contents
- Idioms
- Borrowed types for arguments
- String concatenation with format!
- Constructors and Default
- Collections as smart pointers
- Finalisation in destructors
- mem::take and mem::replace
- On-stack dynamic dispatch
- Iterating over Option
- Pass variables to closure
- non_exhaustive for extensibility
- Temporary mutability
- Return consumed argument on error
- Behavioural patterns
- Creational patterns
- Structural patterns
- Functional patterns
Idioms
Borrowed types for arguments
Prefer &str over &String, &[T] over &Vec<T>, and &T over &Box<T> in function parameters. This avoids unnecessary indirection and accepts more input types through deref coercion.
String concatenation with format!
Prefer format!("Hello {name}!") over manual push/push_str chains for readability. For performance-critical paths where the string can be pre-allocated, manual push operations may be faster.
Constructors and Default
Rust has no language-level constructors. Use an associated function called new to create objects. If the type has a sensible zero/empty state, implement the Default trait. It is common and expected to provide both Default and new, even if they are functionally identical.
Default enables usage with or_default functions throughout the standard library and partial initialization: MyStruct { field: value, ..Default::default() }.
Collections as smart pointers
Implement Deref for owning collections to provide a borrowed view (e.g., Vec<T> → &[T], String → &str). Implement methods on the borrowed view rather than the owning type where possible.
Finalisation in destructors
Use Drop implementations to ensure cleanup code runs on all exit paths (early returns, ?, panics). Assign the guard object to a named variable (not just _) to prevent immediate destruction. Destructors are not guaranteed to run in all cases (infinite loops, double panics).
mem::take and mem::replace
When transforming an enum variant in place, use mem::take(name) to move values out without cloning. This avoids the "clone to satisfy the borrow checker" anti-pattern. For Option fields, prefer Option::take().
On-stack dynamic dispatch
When dynamic dispatch is needed but heap allocation is not, use &mut dyn Trait with temporary values. Since Rust 1.79.0, the compiler automatically extends lifetimes of temporaries in & or &mut.
Iterating over Option
Option implements IntoIterator, so it works with .extend(), .chain(), and for loops. Use std::iter::once as a more readable alternative to Some(foo).into_iter() when the value is always present.
Pass variables to closure
Use a separate scope block before the closure to prepare variables (clone, borrow, move) rather than creating separate named variables like num2_cloned. This groups captured state with the closure's definition.
non_exhaustive for extensibility
Apply #[non_exhaustive] to public structs and enums that may gain fields or variants in the future, to maintain backwards compatibility across crate boundaries. Within a crate, a private field (e.g., _b: ()) achieves a similar effect. Use deliberately — incrementing the major version when adding fields or variants is often better.
Temporary mutability
When data must be prepared mutably but then used immutably, use a nested block or variable rebinding (let data = data;) to enforce immutability after preparation.
Return consumed argument on error
If a fallible function takes ownership of an argument, include that argument in the error type so the caller can recover it and retry. Example: String::from_utf8 returns the original Vec<u8> inside FromUtf8Error.
Behavioural patterns
Command
Separate actions into objects and pass them as parameters. Three approaches: trait objects (complex commands with state), function pointers (simple stateless commands), and Fn trait objects (closures). Use trait objects when commands need multiple functions and state; use function pointers or closures for simple, stateless cases.
Interpreter
Express recurring problem instances in a domain-specific language and implement an interpreter. macro_rules! can serve as a lightweight compile-time interpreter for simple DSLs.
Newtype
Use a tuple struct with a single field to create a distinct type (e.g., struct Password(String)). Provides type safety, encapsulation, and the ability to implement custom traits on existing types. Zero-cost abstraction. Consider the derive_more crate to reduce boilerplate pass-through impls.
RAII with guards
Tie resource acquisition to object creation and release to destruction (Drop). Use guard objects to mediate access — the borrow checker ensures references cannot outlive the guard. Classic example: MutexGuard.
Strategy
Define an abstract algorithm skeleton and let implementations be swapped via traits or closures. Serde is an excellent real-world example: Serialize/Deserialize traits allow swapping formats transparently.
Visitor
Encapsulate an algorithm operating over a heterogeneous collection without modifying the data types. Define visit_* methods on a Visitor trait for each type; provide walk_* helpers to factor out traversal. The visitor can be stateful.
Creational patterns
Builder
Construct complex objects step by step using a separate builder type. Provide a builder() method on the target type. Return the builder by value from each setter for method chaining: FooBuilder::new().name("x").build(). Consider the derive_builder crate.
Fold
Transform a data structure by running an algorithm over each node, producing a new structure. Provide default fold_* methods that recurse into children, allowing implementors to override only the nodes they care about. Related to visitor, but produces a new structure.
Structural patterns
Struct decomposition for independent borrowing
When the borrow checker prevents simultaneous borrows of different fields in a large struct, decompose into smaller structs. Compose them back; each can then be borrowed independently. Often leads to better design.
Prefer small crates
Build small, focused crates that do one thing well. Easier to understand, encourage modularity, and allow parallel compilation. Be mindful of dependency quality.
Contain unsafety in small modules
Isolate unsafe code in the smallest possible module that upholds the needed invariants. Build a safe interface on top. This restricts the audit surface.
Custom traits for complex type bounds
When trait bounds become unwieldy (especially with Fn traits), introduce a new trait with a generic impl for all types satisfying the original bound. Reduces verbosity and increases expressiveness.
Functional patterns
Rust supports many functional paradigms alongside its imperative core:
- Prefer declarative iterator chains (
.fold(),.map(),.filter()) over imperative loops when they improve clarity. - Use generics as type classes. Rust's generic type parameters create type class constraints; different filled-in parameters create different types with potentially different
implblocks. - Apply YAGNI — many traditional OO patterns are unnecessary in Rust due to traits, enums, and the type system.