All posts

Rust Ownership Patterns I Wish I Knew Earlier

Practical ownership patterns that took me too long to internalize — from fighting the borrow checker to working with it.


I learned Rust the hard way: by writing code, watching it fail to compile, and spending an embarrassing amount of time arguing with the borrow checker. This is not the most efficient approach.

What finally clicked for me wasn’t reading more documentation — it was internalizing a handful of patterns that show up everywhere in idiomatic Rust. Here are the ones I wish someone had shown me in week one.

Stop cloning your way out of borrow errors

The first thing most Rust beginners do when they hit a borrow checker error is .clone() their way out of it. I did this constantly. It works, it compiles, and it feels like progress.

The problem is that it usually means you haven’t understood why the error was happening. And in hot paths, you’re now paying for heap allocations that don’t need to exist.

The pattern to learn instead: ask yourself who should own this data. Often the error is telling you that two pieces of code are trying to own the same thing, which means your data structure is wrong — not your borrow usage.

// Before: fighting with clones
fn process(items: &Vec<String>) -> Vec<String> {
    items.iter().map(|s| s.clone()).collect()
}

// After: understand what you actually need
fn process(items: &[&str]) -> Vec<String> {
    items.iter().map(|s| s.to_string()).collect()
}

Newtype for everything meaningful

Rust’s type system is remarkably expressive, and one of the simplest wins is wrapping primitive types in newtypes. UserId(u64) and PostId(u64) are the same at runtime, but the compiler will never let you pass one where the other is expected.

struct UserId(u64);
struct PostId(u64);

fn get_post(user: UserId, post: PostId) -> Option<Post> { /* ... */ }

// This won't compile — types are distinct
// get_post(PostId(1), UserId(2));

This pattern eliminates an entire class of bugs that would otherwise only appear at runtime. It’s free (zero-cost abstraction), and it makes your code self-documenting.

Builder pattern for complex construction

When a struct has more than 3-4 fields, especially with optional fields and validation, reach for the builder pattern. The standard library doesn’t provide a derive macro for this, but the typed-builder or bon crates do, or you can write it by hand.

let config = ServerConfig::builder()
    .host("0.0.0.0")
    .port(8080)
    .max_connections(1024)
    .tls(TlsConfig::from_files("cert.pem", "key.pem")?)
    .build()?;

The key insight: validation happens at build time, not at the callsite. Your Server::new(config) can trust that the config it received is valid.

From / Into as a universal converter

The From and Into traits are Rust’s idiomatic conversion mechanism. Implementing From<SomeError> for your error type means the ? operator just works for that error. Implementing From<String> for a newtype means callers can pass string literals naturally.

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self {
        AppError::Io(e)
    }
}

// Now this works seamlessly
fn read_config(path: &Path) -> Result<Config, AppError> {
    let contents = fs::read_to_string(path)?; // io::Error auto-converts
    Ok(toml::from_str(&contents)?)
}

Spend time implementing these conversions early in a project and your error handling becomes dramatically cleaner.

Prefer impl Trait in function arguments

When writing functions that take callbacks or iterators, impl Trait in argument position is almost always better than a generic bound for the simple case. It’s more readable, and the monomorphization story is the same.

// Verbose generic
fn map_items<F: Fn(&str) -> String>(items: &[&str], f: F) -> Vec<String> { /* ... */ }

// Cleaner with impl Trait
fn map_items(items: &[&str], f: impl Fn(&str) -> String) -> Vec<String> { /* ... */ }

The exception: if you need the type parameter elsewhere (e.g., in the return type or stored in a struct), you still need the explicit generic.

The borrow checker is usually right

The deepest shift in my Rust thinking came when I stopped treating borrow checker errors as obstacles and started treating them as signals. Almost every persistent borrow error is telling you one of two things:

  1. Your data structure doesn’t model ownership clearly
  2. Your code is genuinely doing something that could cause issues in a concurrent context

Fighting through to a Rc<RefCell<T>> solution isn’t always wrong, but it should feel like a concession — something to come back to and reconsider.

The borrow checker is a proof assistant. When it rejects your code, it has a reason. Learning to read that reason is the actual skill.


Writing more Rust content soon. The next post will be about async patterns and the Pin puzzle. Stay tuned.


Back to Writing Get In Touch