Skip to content

Rust Design Patterns

A design pattern is a proven way to solve a problem that keeps coming up. Patterns aren’t libraries you install. They’re more like recipes: “when you have this problem, shape your code like this”.

Many patterns from Java or C# look different in Rust, or aren’t needed at all. Rust has its own favorites, built on its strong type system, enums, traits, and ownership. The big idea behind most of them is the same: let the compiler catch mistakes for you, so they never reach your users. Each section below starts with the problem, then shows the pattern that fixes it.

Newtype: different meanings, different types

Section titled “Newtype: different meanings, different types”

The problem. A plain f64 can mean anything: meters, feet, seconds, euros. If a function takes an f64 in meters and you pass feet, the compiler can’t help — both are just f64. (This kind of unit mix-up is famously how NASA lost the Mars Climate Orbiter in 1999.)

The pattern. Wrap the value in a tiny struct with one field. Now Meters and Feet are different types, and Rust won’t let you mix them up:

#[derive(Debug, Clone, Copy)]
struct Meters(f64);
#[derive(Debug, Clone, Copy)]
struct Feet(f64);
impl Feet {
fn to_meters(self) -> Meters {
Meters(self.0 * 0.3048)
}
}
fn safe_altitude(height: Meters) -> bool {
height.0 >= 300.0
}
fn main() {
let tower = Meters(330.0);
let plane = Feet(900.0);
println!("Tower safe? {}", safe_altitude(tower));
println!("900 feet is {:.1} meters", plane.to_meters().0);
println!("Plane safe? {}", safe_altitude(plane.to_meters()));
}

Output:

Tower safe? true
900 feet is 274.3 meters
Plane safe? false
▶ Try it Yourself

If you forget to convert and write safe_altitude(plane), the program doesn’t even compile:

error[E0308]: mismatched types
--> src/main.rs:13:34
|
13 | println!("{}", safe_altitude(plane));
| ------------- ^^^^^ expected `Meters`, found `Feet`
| |
| arguments to this function are incorrect

A newtype costs nothing at runtime: in memory, Meters is exactly an f64. Use it for IDs (UserId(u32) vs OrderId(u32)), units, and validated strings. It also gets you around the orphan rule (see Advanced Traits).

The problem. Some structs have lots of fields, and most are optional. A constructor like Email::new("[email protected]", "Hi", "", vec![], false) is hard to read — which false is which? And what if the address is missing or wrong?

The pattern. A separate builder struct collects the settings with small, named methods. Each method returns the builder, so you can chain calls. A final build() checks everything and returns a Result:

struct Email {
to: String,
subject: String,
body: String,
urgent: bool,
}
#[derive(Default)]
struct EmailBuilder {
to: Option<String>,
subject: Option<String>,
body: String,
urgent: bool,
}
impl EmailBuilder {
fn new() -> Self {
EmailBuilder::default()
}
fn to(mut self, address: &str) -> Self {
self.to = Some(address.to_string());
self
}
fn subject(mut self, subject: &str) -> Self {
self.subject = Some(subject.to_string());
self
}
fn body(mut self, text: &str) -> Self {
self.body = text.to_string();
self
}
fn urgent(mut self) -> Self {
self.urgent = true;
self
}
fn build(self) -> Result<Email, String> {
let to = self.to.ok_or("an email needs a recipient")?;
if !to.contains('@') {
return Err(format!("'{}' is not an email address", to));
}
let subject = self.subject.unwrap_or_else(|| String::from("(no subject)"));
Ok(Email { to, subject, body: self.body, urgent: self.urgent })
}
}
fn show(result: Result<Email, String>) {
match result {
Ok(e) => println!("Ready: to={} subject={} body={:?} urgent={}", e.to, e.subject, e.body, e.urgent),
Err(msg) => println!("Error: {}", msg),
}
}
fn main() {
let email = EmailBuilder::new()
.subject("Lunch?")
.body("Pizza at 12?")
.urgent()
.build();
show(email);
show(EmailBuilder::new().to("[email protected]").build());
show(EmailBuilder::new().subject("Hello").build());
show(EmailBuilder::new().to("ana.example.com").build());
}

Output:

Ready: [email protected] subject=Lunch? body="Pizza at 12?" urgent=true
Ready: [email protected] subject=(no subject) body="" urgent=false
Error: an email needs a recipient
Error: 'ana.example.com' is not an email address
▶ Try it Yourself

Each setter takes mut self (it takes ownership of the builder), changes one field, and hands the builder back. That’s what makes the chain work. Callers only set what they care about, the names explain each value, and build() is the one place that checks the rules. Inside it, ok_or turns the missing-recipient None into an Err, and ? returns that error early. You’ll see builders in many crates — for example, std::thread::Builder and std::process::Command (see Environment and Processes).

Default and struct update: the lightweight option

Section titled “Default and struct update: the lightweight option”

The problem. A builder is a lot of code. If your struct has no rules to check — just sensible starting values — it’s overkill.

The pattern. Implement Default with good starting values, then use struct update syntax .. to change only the fields you care about:

#[derive(Debug, Clone)]
struct ServerConfig {
host: String,
port: u16,
workers: u32,
verbose: bool,
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig {
host: String::from("127.0.0.1"),
port: 8080,
workers: 4,
verbose: false,
}
}
}
fn main() {
let dev = ServerConfig { verbose: true, ..Default::default() };
let prod = ServerConfig {
host: String::from("0.0.0.0"),
workers: 16,
..ServerConfig::default()
};
// Start from another config instead of the default:
let staging = ServerConfig { port: 9090, ..prod.clone() };
println!("{:?}", dev);
println!("{:?}", prod);
println!("{:?}", staging);
}

Output:

ServerConfig { host: "127.0.0.1", port: 8080, workers: 4, verbose: true }
ServerConfig { host: "0.0.0.0", port: 8080, workers: 16, verbose: false }
ServerConfig { host: "0.0.0.0", port: 9090, workers: 16, verbose: false }
▶ Try it Yourself

..something means “take every field I didn’t list from something”. Why prod.clone()? Struct update moves the remaining fields, and host is a String. Without the clone, prod would be partly moved and you couldn’t print it afterwards. A good rule: use Default + .. when every combination of values is fine, and a builder when some combinations are invalid.

Typestate: a state machine checked at compile time

Section titled “Typestate: a state machine checked at compile time”

The problem. Some things must happen in order. A web request needs a URL before you can send it. With a normal struct, you’d check at runtime (“is the URL set? if not, return an error”) — and hope every caller handles that error.

The pattern. Make the state part of the type. A Request<NoUrl> simply has no send method. Only setting the URL turns it into a Request<HasUrl>, which does:

// The two states. They hold no data (or only what that state needs).
struct NoUrl;
struct HasUrl(String);
struct Request<U> {
url: U,
headers: Vec<(String, String)>,
}
impl Request<NoUrl> {
fn new() -> Self {
Request { url: NoUrl, headers: Vec::new() }
}
// Changes the type: Request<NoUrl> -> Request<HasUrl>
fn url(self, url: &str) -> Request<HasUrl> {
Request { url: HasUrl(url.to_string()), headers: self.headers }
}
}
// Available in every state
impl<U> Request<U> {
fn header(mut self, key: &str, value: &str) -> Self {
self.headers.push((key.to_string(), value.to_string()));
self
}
}
// Only available once a URL is set
impl Request<HasUrl> {
fn send(self) -> String {
format!("GET {} with {} header(s)", self.url.0, self.headers.len())
}
}
fn main() {
let response = Request::new()
.header("Accept", "text/html")
.url("https://example.com")
.header("User-Agent", "rust-tutorial")
.send();
println!("{}", response);
}

Output:

GET https://example.com with 2 header(s)
▶ Try it Yourself

Now try to send without a URL — Request::new().send() — and the compiler stops you:

error[E0599]: no method named `send` found for struct `Request<NoUrl>` in the current scope
--> src/main.rs:22:35
|
22 | let response = Request::new().send();
| ^^^^ method not found in `Request<NoUrl>`
|
= note: the method was found for `Request<HasUrl>`

The bug can’t exist in a program that compiles. There’s no runtime check and no extra cost: NoUrl takes zero bytes. Because url() takes self by value, the old Request<NoUrl> is used up, so nobody can keep using the “before” state by accident. This uses generics in a clever way; it’s worth trying a small change in the playground to feel how it works.

RAII and Drop guards: cleanup you can’t forget

Section titled “RAII and Drop guards: cleanup you can’t forget”

The problem. Resources need cleaning up: close the file, unlock the lock, delete the temp folder, print “done”. If cleanup is a function you must remember to call, someone will forget — especially on an early return or an error.

The pattern. Put the cleanup in Drop. Rust calls drop automatically when a value goes out of scope, on every path out — normal end, early return, ?, even a panic. This is called RAII (“Resource Acquisition Is Initialization”: getting the resource and owning a value are the same step). A value whose only job is to clean up is called a guard:

struct Guard {
name: String,
}
impl Guard {
fn new(name: &str) -> Guard {
println!("start {}", name);
Guard { name: name.to_string() }
}
}
impl Drop for Guard {
fn drop(&mut self) {
println!("clean up {}", self.name);
}
}
fn risky(fail: bool) -> Result<(), String> {
let _guard = Guard::new("risky job");
if fail {
return Err(String::from("something went wrong")); // guard still cleans up
}
println!("job done");
Ok(())
}
fn main() {
{
let _block = Guard::new("block");
println!("inside the block");
} // _block is dropped here
println!("{:?}", risky(false));
println!("{:?}", risky(true));
}

Output:

start block
inside the block
clean up block
start risky job
job done
clean up risky job
Ok(())
start risky job
clean up risky job
Err("something went wrong")
▶ Try it Yourself

You already use this pattern all the time. A File closes itself when dropped. A MutexGuard (from mutex.lock(), see Concurrency) unlocks the mutex when dropped. A Box frees its memory when dropped (Smart Pointers). That’s why Rust code almost never has “close” or “free” calls.

From and Into: friendly function arguments

Section titled “From and Into: friendly function arguments”

The problem. Your function takes a String, so callers with a &str must write String::from("Ana") or "Ana".to_string() every time. Or your function takes a UserId, and callers have a plain u32. Small, but it adds noise everywhere.

The pattern. Implement From for your types, and accept impl Into<T> in your functions. Then callers can pass anything that converts:

#[derive(Debug)]
struct UserId(u32);
impl From<u32> for UserId {
fn from(n: u32) -> Self {
UserId(n)
}
}
#[derive(Debug)]
struct User {
id: UserId,
name: String,
}
impl User {
fn new(id: impl Into<UserId>, name: impl Into<String>) -> Self {
User { id: id.into(), name: name.into() }
}
}
fn main() {
let ana = User::new(1, "Ana"); // u32 and &str
let ben = User::new(UserId(2), String::from("Ben")); // already the right types
println!("{:?}", ana);
println!("{:?}", ben);
}

Output:

User { id: UserId(1), name: "Ana" }
User { id: UserId(2), name: "Ben" }
▶ Try it Yourself

Implement From, never Into directly — you get Into for free. The same trick powers error handling: ? calls From::from on the error, so implementing From<io::Error> for MyError lets ? convert errors for you (see Custom Errors). Use this for conversions that can’t fail. If a conversion can fail, use TryFrom, which returns a Result.

The problem. An order goes from “in cart” to “paid” to “shipped”. A first try often looks like this:

struct Order {
paid: bool,
shipped: bool,
tracking_number: Option<String>,
}

But this allows nonsense: shipped: true with paid: false, or a tracking number on an order that hasn’t shipped. Every function has to check for these impossible combinations.

The pattern. Make illegal states unrepresentable. Use an enum with one variant per state, and give each variant only the data that state really has. Transitions are methods that take the old state and return the new one:

#[derive(Debug)]
enum Order {
Cart { items: Vec<String> },
Paid { items: Vec<String>, amount: u32 },
Shipped { tracking: String },
Cancelled,
}
impl Order {
fn pay(self, amount: u32) -> Order {
match self {
Order::Cart { items } => Order::Paid { items, amount },
other => other, // only a cart can be paid; anything else stays the same
}
}
fn ship(self, tracking: &str) -> Order {
match self {
Order::Paid { .. } => Order::Shipped { tracking: tracking.to_string() },
other => other,
}
}
fn cancel(self) -> Order {
match self {
Order::Shipped { .. } => self, // too late to cancel
_ => Order::Cancelled,
}
}
fn status(&self) -> String {
match self {
Order::Cart { items } => format!("cart with {} item(s)", items.len()),
Order::Paid { items, amount } => format!("paid {} for {} item(s)", amount, items.len()),
Order::Shipped { tracking } => format!("shipped, tracking {}", tracking),
Order::Cancelled => String::from("cancelled"),
}
}
}
fn main() {
let order = Order::Cart { items: vec![String::from("book"), String::from("pen")] };
println!("{}", order.status());
let order = order.ship("XYZ"); // not paid yet: nothing happens
println!("{}", order.status());
let order = order.pay(25).ship("TRACK-42");
println!("{}", order.status());
let order = order.cancel(); // already shipped
println!("{}", order.status());
let other = Order::Cart { items: vec![] }.cancel();
println!("{}", other.status());
}

Output:

cart with 2 item(s)
cart with 2 item(s)
shipped, tracking TRACK-42
shipped, tracking TRACK-42
cancelled
▶ Try it Yourself

A shipped order can’t be missing a tracking number — the Shipped variant always has one. A cart can’t have a tracking number — there’s no field for it. And because match must cover every variant, adding a new state (say, Refunded) makes the compiler point at every place you need to update.

Enums or trait objects? Object-oriented languages often use a “State” interface with one class per state. In Rust you could do that with trait objects (Box<dyn State>), and the Rust Book shows how. Here’s how to choose:

  • Enum — you know all the states up front. You get exhaustive match checks, and all the logic is in one place. This is the usual choice in Rust.
  • Trait objects — other people (or other crates) need to add new states without changing your code.
  • Typestate (from earlier) — you want wrong orders of calls to be compile errors, not just ignored at runtime. It’s stricter, but the state must be known at compile time.

Writing let _ = guard instead of let _guard = guard. They look almost the same, but behave differently. A name starting with an underscore (like _guard) keeps the value alive until the end of the scope. A plain _ is not a variable at all, so the value is dropped immediately:

struct Guard;
impl Drop for Guard {
fn drop(&mut self) {
println!("guard dropped");
}
}
fn main() {
let _ = Guard;
println!("work with `let _`");
let _g = Guard;
println!("work with `let _g`");
}

Output:

guard dropped
work with `let _`
work with `let _g`
guard dropped
▶ Try it Yourself

With let _, the cleanup ran before the work, which is exactly wrong for a lock or a timer. For guards, always give the variable a name.

Using a pattern just because it exists. A builder for a struct with two fields, or a typestate for something that is never misused, adds code without making anything safer. Start simple. Reach for a pattern when you feel the problem it solves.

  • Newtype (struct Meters(f64)) gives the same data different types, so the compiler catches mix-ups — for free at runtime.
  • Builder gives readable, chained setup and one build() that validates and returns a Result. Default + .. is the lighter choice when nothing needs checking.
  • Typestate puts the state in the type (Request<NoUrl> vs Request<HasUrl>), so calling things in the wrong order doesn’t compile.
  • RAII / Drop guards run cleanup automatically on every way out of a scope.
  • From/Into let functions accept many input types with impl Into<T>.
  • Enums model state machines and make illegal states unrepresentable; use trait objects only when others must add states.

Quick check

1. What does the newtype struct Meters(f64) give you over a plain f64?

2. In the builder pattern on this page, what does build() return?

3. In the typestate example, why can't you call send() on Request::new()?

4. When does a Drop guard's cleanup run?

5. What does let _ = Guard; do?

Score: 0 / 5