Skip to main content

The Rust Cheat Sheet I Reach For: Ownership, Result, match, and Traits

A quick reference to the Rust concepts you actually type day to day — ownership and borrowing, Result and the ? operator, match, Option, traits, and collection methods.

Published By Li Lei
#rust #cheat sheet #error handling #ownership #traits

The Rust Cheat Sheet I Reach For: Ownership, Result, match, and Traits

Rust has a reputation for being hard to keep in your head, and I think that reputation is half earned. The syntax is small. What trips people up is a cluster of ideas that show up in almost every function you write: who owns a value, how errors travel up the stack, and how match forces you to handle every case. Once those click, most of the language is just standard library you look up as needed.

This is the short version of what I keep open in a tab while I work. For the full searchable reference with 100+ runnable snippets, the Rust Cheatsheet covers fifteen sections including async, smart pointers, and a pitfalls list. Here I want to walk through the handful of concepts you reach for constantly.

Ownership and borrowing in three lines

Every value in Rust has exactly one owner. When you assign it or pass it to a function, ownership moves, and the old binding is no longer usable:

let s = String::from("hello");
let t = s;            // s is moved into t
// println!("{s}");   // compile error: value borrowed after move

Most of the time you do not want to give a value away — you want to look at it. That is borrowing. A shared borrow &T lets many readers in; a mutable borrow &mut T gives one writer exclusive access:

fn len(s: &str) -> usize { s.len() }      // borrows, does not take
fn push(s: &mut String) { s.push('!'); }  // borrows mutably

let mut name = String::from("rust");
let n = len(&name);    // shared borrow
push(&mut name);       // exclusive borrow

The two rules the borrow checker enforces: you can have any number of &T OR exactly one &mut T, never both at once. That single constraint is what eliminates data races at compile time. When you hit an error here, the fix is usually structural — own the data in your struct, or pass &str instead of String — not reaching for .clone() to make the compiler quiet.

Option instead of null

Rust has no null. A value that might be absent is an Option<T>, which is either Some(value) or None. The compiler will not let you use the inner value without first checking which case you have:

let found: Option<&User> = users.get("lilei");

match found {
    Some(user) => println!("hi {}", user.name),
    None => println!("no such user"),
}

For the common shortcuts there are helpers — found.unwrap_or(&default), found.map(|u| u.id), or the if let Some(user) = found { ... } form when you only care about the present case. The point is the same: an absent value is a type you have to acknowledge, not a landmine you trip over at runtime.

Result and the ? operator

Recoverable errors use Result<T, E>, which is Ok(value) or Err(error). You could match on every result by hand, but that gets noisy fast. The ? operator is the whole reason Rust error handling stays readable: it unwraps an Ok, or returns the Err early from the enclosing function.

fn parse_port(raw: &str) -> Result<u16, std::num::ParseIntError> {
    let port: u16 = raw.parse()?;   // returns Err early on failure
    Ok(port)
}

That single ? replaces a four-line match that pulls the error out and returns it. The signature fn f() -> Result<T, E> plus ? on every fallible call is the backbone of real Rust code.

A worked scenario: propagating an error with ?

Say you are reading a config file and parsing a number out of it. Two things can fail — the file read and the parse — and they have different error types. Here is the naive version, fully spelled out:

use std::fs;

fn read_timeout(path: &str) -> Result<u32, String> {
    let text = match fs::read_to_string(path) {
        Ok(t) => t,
        Err(e) => return Err(format!("read failed: {e}")),
    };
    let n = match text.trim().parse::<u32>() {
        Ok(n) => n,
        Err(e) => return Err(format!("parse failed: {e}")),
    };
    Ok(n)
}

That works, but it is mostly ceremony. With a single error type that both underlying errors convert into, ? collapses the whole thing:

use std::fs;
use std::error::Error;

fn read_timeout(path: &str) -> Result<u32, Box<dyn Error>> {
    let text = fs::read_to_string(path)?;   // io::Error converts in
    let n = text.trim().parse::<u32>()?;    // ParseIntError converts in
    Ok(n)
}

Box<dyn Error> accepts any error type that implements the Error trait, and ? calls From to convert each one on the way out. In a binary you can even write fn main() -> Result<(), Box<dyn Error>> and ? your way through the whole program. In a library you publish, swap the box for a named enum with one variant per failure mode so callers can match and recover — the thiserror crate generates the Display, Error, and From impls for you.

Traits: shared behavior without inheritance

Traits are how Rust does polymorphism. A trait is a set of method signatures a type can implement, and you can implement your own traits on built-in types or others' traits on your types (within the orphan rule). The pair you meet first is Display for user-facing text and Debug for {:?} developer output:

use std::fmt;

struct Point { x: i32, y: i32 }

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

Trait bounds let a generic function accept anything that satisfies the trait: fn show<T: Display>(item: T). For runtime polymorphism you use dyn Trait (dynamic dispatch through a pointer); for the zero-cost compile-time version you use impl Trait. From and Into are the conversion traits that make ? work — implement From<LowLevelError> for your error type and propagation is automatic.

Collection methods you use every day

Vec<T> and HashMap<K, V> carry most of your data, and the iterator adapters do most of the work. Three method families cover the bulk of it:

let nums = vec![1, 2, 3, 4, 5];

let doubled: Vec<i32> = nums.iter().map(|n| n * 2).collect();
let evens: Vec<&i32> = nums.iter().filter(|n| *n % 2 == 0).collect();
let total: i32 = nums.iter().sum();

let has_big = nums.iter().any(|n| *n > 4);     // true
let first_big = nums.iter().find(|n| **n > 3); // Some(&4)

Note the three flavors of iteration that confuse newcomers: iter() borrows each element (&T), iter_mut() borrows mutably (&mut T), and into_iter() consumes the collection and hands you owned values (T). Pick the one that matches what you need to do with each item. collect() is the workhorse on the output side — it builds a Vec, a HashMap, a String, or even a Result that short-circuits on the first Err.

How I actually use this

When I am stuck on Rust, it is almost never the syntax — it is one of these five ideas applied somewhere I did not expect. Last month I was porting a small service and the compiler rejected a line that read fine to me. I had returned a &str borrowed from a local String that went out of scope at the end of the function. No amount of staring fixed it; pulling up the ownership entries and reading move versus borrow side by side did, in about two minutes. The lesson I keep relearning: when the borrow checker fights you, the answer is usually to change who owns the data, not to annotate harder.

If Rust sits next to other languages in your stack, the same quick-reference treatment exists for Go, Python, TypeScript, and PostgreSQL. Keep the one you are switching into open in a tab and the context-switch tax drops a lot.

Ownership, Option, Result with ?, match, traits, and the iterator methods are the load-bearing concepts. Everything else — lifetimes, async, smart pointers — builds on top of these. Get comfortable with the six above and you can read almost any Rust file without reaching for the book.


Made by Toolora · Updated 2026-06-13