2025-07-30

Tips for Defensive Programming in Rust

Rust gives you a lot of defensive programming features “out of the box” - immutability by default, exhaustive pattern matching, and a type system that catches many bugs at compile time. But there’s still a lot you can do to make your code even more robust.

What follows is a list of best practices I’ve collected that a lot of the time feel worth it to me. Use your best judgment for which of these are worth it for your projects!

Make Invalid States Unrepresentable

This is a well-known principle, but it remains one of the most important. If you can encode your invariants in the type system, the compiler becomes your ally in maintaining them. There are a few flavors of this:

Use Sum Types Liberally

If you ever find that you’ve created a type where certain states are “wrong” or should never happen, consider rethinking how you represent your data:

struct Character {
   name: String,
   health: u32,
   archetype: Archetype,
   // None for all except mage type
   mana: Option<u32>,
   // Only tracked for Knights
   weight_kg: Option<u32>,
}

pub enum Archetype {
    Mage,
    Knight,
}

What are the illegal states here?

  • A Knight with mana (should be None)
  • A Mage with weight_kg (should be None)
  • Either archetype with both fields as Some(...)

Since these fields are mutually exclusive, they should be moved to the enum variants.

struct Character {
   name: String,
   health: u32,
   archetype: Archetype,
}

pub enum Archetype {
    Mage { mana: u32 },
    Knight { weight_kg: u32 },
}

This makes it much easier for consumers of a Character to know that each archetype has been handled correctly. In general, whenever you can find a state that is “invalid”, try to restructure your type so that this is impossible. When this approach becomes difficult, consider these alternatives:

Encapsulate Data in Validating Containers

For values with constraints that cannot be represented by the type system itself, wrap them in a container that enforces those constraints. This can be useful both for newtypes:

struct BoundedInt<const MIN: i32, const MAX: i32>(i32);

impl<const MIN: i32, const MAX: i32> BoundedInt<MIN, MAX> {
    pub fn new(value: i32) -> Result<Self, String> {
        if value < MIN || value > MAX {
            Err(format!("Value {} out of range [{}, {}]", value, MIN, MAX))
        } else {
            Ok(BoundedInt(value))
        }
    }
}

type Percentage = BoundedInt<0, 100>;

Or for more complex structures:

struct Street {
    cars: Vec<Car>,
    people: Vec<Person>,
};

struct Person {
    name: String,
    //...
}

struct Car {
    owner: String,
    //...
}

For complex objects like the one above, there are two flavors.

Validate for each operation

let street = Street::new();
// doesn't require validation
street.add_person("alice");
// requires that alice exists
street.add_car("49XAD", "alice").unwrap();

Sometimes you might be able to construct this interface in a way where it’s impossible to perform the wrong operation:

struct Library {
    books: HashMap<BookId, Book>,
    index_by_author: Vec<BookId>,
    index_by_title: Vec<BookId>
}

//...

let library = Library::new();
// automatically updates the indexes
library.add_book("All flowers with 5 petals", "Tolkien");

I prefer this approach when:

  • Validation can be avoided entirely through API design
  • The application frequently alternates between updating and using the data

Validate once

Validate once, either when constructing a type from other data sources, or as a final step when finishing a builder.

// From data sources
let street: Street = Street::new(people, cars).unwrap();

// Builder (call site)
let street: Street = StreetBuilder::new()
                .add_person(..)
                .add_car(..)
                .build().unwrap();

I prefer this approach when:

  • The type is constructed once and only needs immutable access afterward

Use the Typestate Pattern

If your type naturally transitions between states, consider representing this at compile time using the typestate pattern. This resembles an enum, but with the variant known at compile time:

struct Rocket<State> {
    fuel_kg: u32,
    state: State
}

struct OnGround;

struct Flying {
    thrust_newton: u32,
}

// Only allow refueling on ground
impl Rocket<OnGround> {
    fn add_fuel(..) { .. }
    fn liftoff() -> Rocket<Flying> { .. }
}

// Only allow increasing thrust when flying
impl Rocket<Flying> {
    fn increase_thrust(..) { .. }
}

//...

let rocket: Rocket<OnGround> = Rocket::new();
rocket.add_fuel(..);
let rocket: Rocket<Flying> = rocket.liftoff();
rocket.increase_thrust(..);

You can read more in this blog post (not by me).

Pattern Matching

When doing pattern matching, ask yourself: “Would I like colleagues to know about this match statement if they add an enum variant to this type?”

If the answer is yes, do exhaustive matching! If your answer is maybe, do exhaustive matching! Only use _ if there is really a natural default behavior you know will likely never change.

If you want to go all the way, there is a clippy lint for this (might be overkill, depending on your use case, but can always be allowed in specific places).

If you find yourself using all fields of a struct in a function, such as when implementing std::fmt::Display: use struct destructuring (irrefutable matching on a struct):

fn to_map(person: Person) -> HashMap<&'static str, String> {
   let Person { name, age } = person;
   let mut map = HashMap::new();
   map.insert("name", name);
   map.insert("age", age.to_string());
   map
}

// Matching directly in function signatures is also a thing:
fn to_map(Person {name, age}: Person) -> HashMap<&'static str, String> {
   let mut map = HashMap::new();
   map.insert("name", name);
   map.insert("age", age.to_string());
   map
}

This way, any future additions to the type will result in a compiler error, forcing the changer to consider what to do in your function.

Error Handling

Each error handling decision should be deliberate. It can be tempting to just sprinkle .unwrap() and ? everywhere and call it a day. Don’t!

Think through if there is a natural default in the case of a call returning an error. If you don’t want to deal with the decision yet, it’s in my opinion better to use unwrap() as a marker to get back to it later than to add ? without making a deliberate decision.

Some less-known helper methods useful for “massaging” errors/options are:

  • and_then(|v| ..) - use for converting Option<Option<T>> or Result<Result<T, E>, E> into an Option<T> or Result<T, E>.
  • transpose() - if you have an Option<String> and would like to parse it to an Option<u32>, returning an error if it fails to parse. You can do so with value.map(|v| v.parse()).transpose()? (totally valid to just use a match statement here too).

Reading through the docs for Result/Option is IMO very worthwhile!

Use different unwrapping methods to communicate intent within your team:

  • .unwrap() - This could fail but is unlikely/not worth handling now (easy to search for/review later).
  • .expect("reason") or unreachable!("reason") - Should never fail, with local reasoning. If you find yourself using this often with the same reason, make it a function!

Enforce Standards with Tooling

Use Clippy’s disallowed_* lints for stylistic choices you’d like to enforce throughout the codebase:

# In clippy.toml or .cargo/config.toml
disallowed-types = [
    # Use parking lot mutex instead
    { path = "std::sync::Mutex", reason = "use parking_lot Mutex instead" },
]
# disallowed-methods = ..

Prefer Safe Conversions

Use value.try_into().unwrap() instead of casting (value as i64) in all non-performance critical paths. Casting will wrap on overflow instead of resulting in a panic!

This one came from my colleague Alex.

Conclusion

What defensive programming patterns have you found useful in Rust? I’d love to expand this list!