Patterns and Antipatterns
A pattern is a shape that recurs because it solves a recurring problem. The Gang
of Four catalogued twenty-three of them for object-oriented design, and a good
half of those exist to work around the absence of first-class functions or sum
types. Rust has both, so several of the classic patterns dissolve: Strategy is a
closure, Command is a closure, Visitor is a match, Iterator is in the standard
library, and Singleton is usually a mistake.
What is left is a smaller set of shapes that are genuinely Rust’s, plus a list of habits that produce code which compiles and fights you. This chapter is both, written to be recognized on sight rather than read through.
Patterns
Newtype
A single-field tuple struct wrapping another type, to make a distinction the compiler enforces.
pub struct Sanitized(String);
impl Sanitized {
pub fn new(raw: &str) -> Sanitized {
Sanitized(raw.replace('<', "<").replace('>', ">"))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
fn render(content: &Sanitized) -> String {
format!("<p>{}</p>", content.as_str())
}
fn main() {
println!("{}", render(&Sanitized::new("<script>alert(1)</script>")));
}
Use it for identifiers, units, validated values, and anything where two values of the same underlying type mean different things. It costs nothing at runtime, and it is also the way past the orphan rule when you need to implement a foreign trait for a foreign type.
Builder
A separate type that accumulates configuration and produces the real value, solving the problem that Rust has neither optional parameters nor overloading.
pub struct Server {
host: String,
port: u16,
threads: usize,
}
pub struct ServerBuilder {
host: String,
port: u16,
threads: usize,
}
impl ServerBuilder {
pub fn new() -> ServerBuilder {
ServerBuilder { host: String::from("localhost"), port: 8080, threads: 4 }
}
pub fn host(mut self, host: &str) -> ServerBuilder {
self.host = String::from(host);
self
}
pub fn port(mut self, port: u16) -> ServerBuilder {
self.port = port;
self
}
pub fn build(self) -> Server {
Server { host: self.host, port: self.port, threads: self.threads }
}
}
fn main() {
let server = ServerBuilder::new().host("0.0.0.0").port(443).build();
println!("{}:{} on {} threads", server.host, server.port, server.threads);
}
Each setter takes self and returns it, which is what makes the calls chain.
Reach for it when a type has more than three or four construction parameters, or
when most of them have sensible defaults. For the simpler case where the defaults
are all you need, #[derive(Default)] plus struct update syntax —
Server { port: 443, ..Default::default() } — does the job with no builder at
all.
Typestate
A state machine encoded as separate types, with transitions that consume the
previous state. Chapter 6 developed this at length. The signature is a method
taking self by value and returning a different type, and the payoff is that
calling an operation in the wrong state is a compile error rather than a runtime
one.
The RAII guard
A value whose destruction performs an action, so that the action cannot be skipped.
struct Timer {
label: &'static str,
}
impl Timer {
fn start(label: &'static str) -> Timer {
println!("[{label}] start");
Timer { label }
}
}
impl Drop for Timer {
fn drop(&mut self) {
println!("[{}] done", self.label);
}
}
fn process() {
let _timer = Timer::start("process");
println!("working");
}
fn main() {
process();
}
MutexGuard, Ref, File and TempDir are all instances. Write one whenever
“finished” means an action rather than merely freeing memory: releasing a lock,
committing or rolling back, restoring a terminal, removing a temporary file,
closing a span in a trace. Bind it to a name — let _guard = ..., not let _ = ..., which drops it immediately.
The extension trait
A trait defined solely to add methods to a type you do not own.
trait Truncate {
fn ellipsize(&self, width: usize) -> String;
}
impl Truncate for str {
fn ellipsize(&self, width: usize) -> String {
if self.chars().count() <= width {
self.to_string()
} else {
let kept: String = self.chars().take(width.saturating_sub(1)).collect();
format!("{kept}…")
}
}
}
fn main() {
println!("{}", "the rust thought process".ellipsize(12));
}
Name them ...Ext by convention when they are part of a public API. It is how
crates add capability to standard types without wrappers.
Sans-IO
Separate the logic that decides from the code that performs input and output. A parser that takes bytes and returns events, a protocol state machine that takes messages and returns messages to send, a rules engine that takes facts and returns decisions — none of them touching a socket or a file.
The Rust-specific reason this pays is that pure logic needs no async, no
lifetimes tied to a connection, and no mocking framework. It is testable with
ordinary values, it works under any runtime, and the I/O shell around it is small
enough to review. The Policy trait in chapter 16 is a small instance of the
same instinct.
Identity over pointers
When two things refer to each other, give one an identifier and let the other hold it. Chapter 2 introduced this and chapter 12 developed it into the arena. Recognize it as a pattern rather than a workaround: identifiers serialize, compare, hash, cross threads, and survive the thing they name being moved in memory. Pointers do none of that.
Antipatterns
Cloning to make the compiler stop
The most common, and the one with the longest tail. A borrow error appears,
.clone() removes it, and the design question goes unasked.
The problem is not the allocation. It is that each clone hides a decision that was never made — usually that a function is taking ownership when it wanted a borrow, or that two parts of the program are arguing about who owns something. Chapter 2’s four answers are the checklist: does one of them only need to read, can they take turns, do they genuinely need independent values, or should the thing be identified rather than pointed at.
Cloning deliberately, in cold code, for a reason you can state, is fine.
unwrap as punctuation
Option and Result exist to force a decision at the point where absence or
failure appears. unwrap answers “crash”, which is sometimes right and is rarely
the answer you would have chosen if asked.
The test from chapter 7: can you state, in one sentence, why the failure is
impossible, and is that sentence about the code rather than about what you hope
the world will do? If yes, use expect with that sentence as the message. If no,
the type was pointing at a case you have not designed.
Rc<RefCell<T>> by default
Reaching for shared ownership plus interior mutability the first time the borrow checker objects reproduces the language you came from, at the cost of runtime panics where compile errors used to be and no static knowledge of who owns anything.
It is the right tool for genuine shared mutable graphs. It is the wrong tool for “I could not work out how to structure this”, and the difference is whether you walked down chapter 12’s ladder first.
Stringly-typed everything
Identifiers, states, kinds and categories represented as String, compared with
== against literals, with typos discovered at runtime. A status field holding
"approved" is an enum that has not been written yet, and a String parameter
named customer_id is a newtype that has not been written yet.
The tell is a comparison against a string literal, or a function whose parameters
are three Strings in an order nobody can remember.
Lifetimes on a storage type
Adding <'a> to a struct so that a field can be &str instead of String, and
then threading the parameter through every type that contains it. Chapter 4’s
rule: a lifetime parameter says the type is a temporary view. If the type is
stored, returned up several layers, or held across an await, it should own its
data, and the allocation you avoided was cheaper than the complexity you bought.
Deref for reuse
Implementing Deref on a newtype so that the inner type’s methods appear on the
outer one, as a substitute for inheritance. It works until it does not: method
resolution follows Deref and trait resolution does not, so the type appears to
have methods it cannot use generically, and the error messages are poor. Deref
is for smart pointers. Write the delegating methods.
&String, &Vec<T>, &Box<T> in signatures
Taking a reference to an owned container when a slice would do. &str accepts
string literals and sub-ranges; &String does not. &[T] accepts arrays,
vectors and slices; &Vec<T> does not. There is nothing you can do with the
narrow form that you cannot do with the wide one, so the narrow form only
restricts callers.
The same applies to impl AsRef<Path> versus &PathBuf, and to taking String
by value when the function only reads it.
The god struct
One type with forty fields and a hundred methods, because everything needs
everything. Rust makes this painful earlier than other languages do, since a
method taking &mut self borrows all of it and blocks every other access.
Chapter 3 named the signal: when the same borrow conflict keeps appearing, the fields involved want to be their own type. Treat recurring borrow errors in one place as structural feedback rather than as an obstacle.
Abstraction bought on speculation
A trait with one implementor, a generic parameter with one instantiation, a
Box<dyn Trait> where a concrete type would do. Each costs indirection,
compile time and a layer for the reader to see through, and returns nothing until
the second implementation arrives.
The Rust-specific version is over-generic function signatures: six type parameters with eight bounds, where two concrete types would have been clearer and just as reusable. Write the concrete version, and generalize when the second case shows up.
Ignoring a Result
let _ = fallible(); discards a failure silently, which is the empty catch
block with better syntax. Where ignoring is genuinely right — best-effort
cleanup, a log write that must not take down the program — make the decision
visible by logging the error explicitly, so the next reader sees a choice rather
than an oversight.
Conclusion
The patterns worth knowing in Rust are mostly small: a wrapper that makes a distinction real, a builder for types with many options, a state machine in the type system, a value whose destruction does the cleanup, a trait that adds methods from outside, logic separated from I/O, and identity in place of pointers. Several classic object-oriented patterns are absent because the language provides directly what they were reconstructing.
The antipatterns share a single root, which is using a mechanism to stop the
compiler talking rather than to answer what it asked. clone, unwrap,
Rc<RefCell<T>> and a speculative lifetime parameter are all legitimate tools
and all common ways to avoid a decision. The question that separates the two
cases is the same every time: can you say what the design reason was?
That question is what this book has been about. The compiler asks who owns this, who may change it, how long it lives, and what states it can be in. Those are questions about your program, not about Rust, and they had answers before you started writing Rust. Now they are written down, and something checks them.
Questions to think through
Pick three patterns from the Gang of Four catalogue and decide what each becomes in Rust. Which ones disappear entirely, and what feature replaced them?
Search a Rust project you have written for .clone(). For each one, write the
sentence explaining why a second independent value was needed. How many
sentences are about the design, and how many are about the compiler?
The chapter claims the antipatterns share one root. Test it: take one you recognize from your own code and ask what question you were avoiding when you wrote it.