When code works but fights you
You write a function to parse a configuration file. It compiles. It runs. Then a user provides a file with a missing field, and your entire process crashes. You wanted to skip the bad line and log a warning. Instead, you got a stack trace. Or you write a loop to transform a list of numbers. You manage an index variable, check bounds manually, and accidentally access one element past the end. Rust saves you from a segfault, but the code is verbose and fragile. You're writing Rust that looks like C. You're fighting the type system instead of using it.
Rust provides patterns that make code safer, shorter, and more composable. Ignoring them leads to anti-patterns: using panic! for recoverable errors, managing indices manually, cloning data to silence the borrow checker, and hiding missing values behind sentinel returns. These habits work around the language's strengths. They hide bugs until runtime and make refactoring painful. Adopting the idioms turns the compiler from an adversary into a partner.
Panic is a bomb, not a return value
panic! stops the thread. It unwinds the stack, drops values, and terminates execution. It is designed for unrecoverable states: bugs, invariants violated, or conditions that should never happen. Using panic! for expected errors like division by zero, missing files, or invalid input is an anti-pattern. It forces the caller to crash or wrap the call in a catch, which Rust doesn't support in safe code.
The idiomatic alternative is Result<T, E>. Result encodes success and failure in the type system. It forces the caller to handle both paths. This enables composition. You can chain operations that might fail without nesting if statements. The ? operator propagates errors cleanly.
/// Divide two integers, returning an error if the divisor is zero.
/// Returns Result to let the caller decide how to handle the failure.
fn divide(a: i32, b: i32) -> Result<i32, String> {
// Check the precondition before performing the operation.
// Returning Err signals that recovery is possible.
if b == 0 {
return Err("Cannot divide by zero".to_string());
}
// Wrap the successful value in Ok.
// This forces the caller to match or use the ? operator.
Ok(a / b)
}
/// Calculate a ratio, propagating errors from divide.
/// The ? operator extracts the value or returns early with the error.
fn calculate_ratio(numerator: i32, denominator: i32) -> Result<f64, String> {
// Use ? to handle the Result.
// If divide returns Err, this function returns immediately.
let quotient = divide(numerator, denominator)?;
Ok(quotient as f64 / 2.0)
}
Convention aside: in production code, avoid String as the error type. It loses context and forces allocations. Define a custom error enum and derive std::fmt::Display. The thiserror crate automates this. Using String is fine for quick scripts, but custom errors scale better.
If you use panic! for recoverable errors, you lose control flow. The caller cannot recover. You also lose the ability to compose functions. Result enables pipelines. panic! breaks them.
Treat panic! as a bug report. If the program panics, something is wrong with your code or your assumptions. Fix the bug. Don't use panic as a control flow tool.
Iterators eliminate index bugs
Manual loops with index variables are error-prone. You have to initialize the index, check bounds, increment, and access elements. Off-by-one errors are common. Iterators handle all of this. They provide a safe, expressive way to process collections. They compose with methods like map, filter, enumerate, and zip. The compiler guarantees bounds safety.
/// Sum a slice of integers using an iterator.
/// Iterators handle bounds checking and composition automatically.
fn sum_iter(numbers: &[i32]) -> i32 {
// iter() borrows the slice and yields references.
// sum() consumes the iterator and returns the total.
// This is zero-cost: the compiler inlines the loop.
numbers.iter().sum()
}
/// Filter even numbers and double them.
/// Chaining iterator methods expresses intent clearly.
fn process_numbers(numbers: &[i32]) -> Vec<i32> {
// filter keeps elements where the closure returns true.
// map transforms each kept element.
// collect gathers the results into a Vec.
numbers
.iter()
.filter(|&&x| x % 2 == 0)
.map(|&x| x * 2)
.collect()
}
Walkthrough: numbers.iter() creates an iterator that yields &i32. The iterator tracks its position internally. When you call next(), it returns the next element or None when exhausted. Methods like filter and map return new iterators that wrap the previous one. collect drives the chain, pulling values until the iterator is empty. No index variable exists. No bounds check can fail. The compiler generates code equivalent to a manual loop, but safer and more readable.
Pitfall: for i in 0..numbers.len() is the anti-pattern. It requires manual indexing and risks out-of-bounds access if the logic changes. If you need the index, use enumerate(). It yields (index, value) pairs safely.
Convention aside: prefer iter() for borrowing, iter_mut() for mutation, and into_iter() for consuming. into_iter() moves values out of the collection. Use it when you own the data and don't need it afterward. Mixing these up causes compiler errors. If you try to move from a borrowed slice, the compiler rejects you with E0507 (cannot move out of borrowed content).
Reach for iterators. They eliminate index bugs by removing indices. If you find yourself writing an index variable, stop. Refactor to an iterator.
Option makes absence explicit
Returning sentinel values like -1 or null to indicate missing data is ambiguous. Is -1 a valid result? Is null a bug or expected? Option<T> encodes absence in the type system. Some(T) holds a value. None holds nothing. The compiler forces you to handle both cases. This prevents null pointer exceptions and silent bugs.
/// Find the maximum value in a slice.
/// Returns None if the slice is empty.
fn find_max(numbers: &[i32]) -> Option<i32> {
// iter() yields references.
// clone() is needed here to move values out, but in real code
// you'd use reduce or a loop to avoid cloning.
// This example shows Option usage, not optimal max finding.
if numbers.is_empty() {
return None;
}
let mut max = numbers[0];
for &x in &numbers[1..] {
if x > max {
max = x;
}
}
Some(max)
}
/// Use Option with pattern matching.
/// The compiler ensures all cases are handled.
fn print_max(numbers: &[i32]) {
match find_max(numbers) {
Some(val) => println!("Max is {}", val),
None => println!("No values to compare"),
}
}
Convention aside: Option has helper methods that reduce boilerplate. unwrap_or(default) provides a fallback. map(f) transforms the value if present. and_then(f) chains operations that return Option. Use these instead of match when the logic is simple. unwrap() and expect() are for cases where None is impossible or indicates a bug. expect(msg) is preferred over unwrap() because it documents the invariant.
If you return a sentinel value, the caller might forget to check it. You get silent corruption. Option makes the check mandatory. The compiler rejects code that ignores Option.
Encode absence in the type. If a value can be missing, the type must say so. Don't hide missing data behind magic numbers.
Cloning to silence the borrow checker
New Rustaceans often clone data to avoid borrow checker errors. They see E0382 (use of moved value) or E0502 (cannot borrow as mutable) and reach for .clone(). Cloning allocates memory and copies data. It hurts performance and obscures intent. The borrow checker exists to help you write safe, efficient code. Learn to borrow instead of cloning.
/// Greet a user by name.
/// Accepts &str to avoid cloning and support string literals.
fn greet(name: &str) {
// &str is a borrowed string slice.
// It works with String, &String, and string literals.
println!("Hello, {}!", name);
}
/// Process data without taking ownership.
/// Using references avoids unnecessary allocations.
fn process(data: &[i32]) -> i32 {
// Borrow the slice.
// The caller retains ownership.
data.iter().sum()
}
Convention aside: accept &str for string parameters, not String or &String. &str is more flexible. It accepts string literals, String, and &String. Accepting String forces the caller to allocate or clone. Accepting &String is less flexible than &str. The community calls this the "accept the most general type" rule.
If you clone to silence the borrow checker, you're treating the symptom, not the cause. Analyze lifetimes. Use references. Restructure the code to share ownership safely. Rc<T> or Arc<T> help when multiple owners are needed. Cloning is a crutch.
Borrow by default. Clone only when you own the data and need to keep it. If you clone, ask yourself: do I really need a copy?
Accept slices, not vectors
Functions that take Vec<T> force the caller to allocate a vector. This is inefficient and restrictive. The caller might have a slice, an array, or a different collection. Accept &[T] instead. Slices are views into contiguous memory. They work with vectors, arrays, and slices. This makes your API flexible and zero-cost.
/// Sort a collection of integers.
/// Accepts a slice to support Vec, arrays, and other collections.
fn sort_collection(data: &mut [i32]) {
// slice::sort_mut sorts in place.
// This works on any mutable slice.
data.sort_unstable();
}
fn main() {
let mut vec = vec![3, 1, 2];
sort_collection(&mut vec);
let mut array = [4, 5, 6];
sort_collection(&mut array);
}
Convention aside: use &[T] for read-only access and &mut [T] for mutation. Avoid Vec<T> in function signatures unless you need to take ownership. If you need to return a collection, return Vec<T> or Cow<[T]> for flexibility. The community expects slice parameters.
If you accept Vec<T>, callers pay for allocations they might not need. You also break composition with other collection types. Slices are the idiomatic choice.
Accept slices. Make your functions work with any contiguous data. Don't force allocations.
The unwrap trap
unwrap() extracts the value from Result or Option, panicking on error or None. It's convenient for quick scripts. It's dangerous in production code. unwrap() hides errors. It turns recoverable failures into crashes. It makes debugging hard because the panic message might not explain the root cause.
The anti-pattern is using unwrap() everywhere. The fix is using ? for propagation, match for handling, or expect(msg) for intentional panics. expect documents why the value should be present. It turns a silent crash into a clear invariant violation.
/// Parse a number from a string.
/// Returns Result to handle invalid input gracefully.
fn parse_number(input: &str) -> Result<i32, std::num::ParseIntError> {
// parse() returns Result.
// Propagate the error with ?.
input.parse()
}
/// Use expect only when None indicates a bug.
/// The message documents the invariant.
fn get_first(numbers: &[i32]) -> i32 {
// expect panics with a message if the slice is empty.
// This documents that the caller must provide a non-empty slice.
*numbers.first().expect("Slice must not be empty")
}
Pitfall: unwrap() in library code is especially bad. Library users can't control the panic. They get a crash instead of an error they can handle. Always return Result from public APIs. Use unwrap() only in main or tests where crashing is acceptable.
If you unwrap, you're betting the program crashes. Make that bet intentional. Replace unwrap with ?. If you can't, you're hiding a bug.
Decision matrix
Use Result<T, E> when the operation can fail and the caller needs to recover. Use panic! when the program is in an invalid state and continuing would be unsafe or nonsensical. Use iterators when processing collections, transforming data, or filtering elements. Use indexing only when you must access elements by position and cannot express the logic with an iterator. Use Option<T> when a value might be absent. Use &str for string parameters instead of String to avoid unnecessary allocations. Use &[T] for collection parameters to support vectors, arrays, and slices. Use expect(msg) instead of unwrap() when you must panic, to document the invariant.