When generics need rules
You write a function to find the largest value in a slice. It works perfectly for i32. You copy-paste the function and change the type to f64. It works again. Then you realize you need the same logic for u8, String, and a custom Score struct. You have four identical functions differing only by type. You try to make it generic with <T>, but the compiler rejects you. It doesn't know how to compare two T values. You need to tell the compiler what T is allowed to do.
That's what trait bounds are for. They constrain generic types to only those that implement specific traits. A trait bound is a contract. It says, "I accept any type, as long as that type promises to provide this behavior." Without the bound, the compiler refuses to generate code because it can't guarantee the operations you're using exist.
The shape of the slot
Think of a generic type parameter like an empty slot in a machine. Without constraints, you could shove anything into that slot. A rock, a feather, a live grenade. The machine would break because it doesn't know how to handle the input.
A trait bound defines the shape of the slot. It says, "I accept anything, as long as it has a handle shaped like this." If the type implements the trait, it fits. If not, the compiler rejects it before the machine runs. This keeps your code safe. You never have to check at runtime whether a type supports an operation. The compiler proves it exists at compile time.
Minimal example: comparing values
Here is the largest function with a trait bound. The syntax T: PartialOrd means T must implement the PartialOrd trait.
fn largest<T: PartialOrd>(list: &[T]) -> &T {
// Start with the first element as the current champion.
let mut largest = &list[0];
for item in list {
// PartialOrd provides the > operator.
// Without this bound, the compiler wouldn't know how to compare item and largest.
if item > largest {
largest = item;
}
}
largest
}
The PartialOrd trait gives you comparison operators like >, <, >=, and <=. By adding the bound, you unlock these operators for T. The compiler checks that whatever type you pass actually implements PartialOrd. If you try to call largest with a type that doesn't implement it, the code won't compile.
What happens under the hood
Trait bounds have zero runtime cost. Rust uses static dispatch. When you call largest(&[1, 2, 3]), the compiler sees that T is i32. It checks the bound: does i32 implement PartialOrd? Yes. It generates a version of largest specifically for i32. This process is called monomorphization.
The generated code is identical to if you had written the function for i32 by hand. There are no virtual tables. No dynamic lookups. No overhead. The bound is a compile-time check that enables code generation. Once the code is generated, the bound disappears. The machine code just compares integers directly.
This is why Rust generics are fast. The compiler does the work upfront. You get the flexibility of generics with the performance of specialized code. Trust the monomorphization. It turns your generic constraints into concrete, optimized instructions.
Realistic example: multiple bounds and where clauses
Real code often needs more than one trait. Suppose you want to find the largest item and return it by value instead of by reference. You need Copy to duplicate the value. You also want to log the result, so you need Debug.
fn largest_and_log<T: PartialOrd + Copy + std::fmt::Debug>(list: &[T]) -> T {
// Copy allows us to take the first element by value.
let mut largest = list[0];
for item in list {
if item > largest {
largest = item;
}
}
// Debug trait lets us print the result with {:?}.
println!("Found largest: {:?}", largest);
largest
}
The + syntax chains multiple bounds. T must implement all of them. When the signature gets crowded, the where clause cleans it up. It's the same thing, just moved below the function signature.
fn largest_and_log<T>(list: &[T]) -> T
where
T: PartialOrd + Copy + std::fmt::Debug,
{
let mut largest = list[0];
for item in list {
if item > largest {
largest = item;
}
}
println!("Found largest: {:?}", largest);
largest
}
Convention favors the where clause when you have multiple bounds, complex bounds involving associated types, or the return type is already cluttered with lifetimes. For a single simple bound, keep it inline. cargo fmt formats both styles consistently, so don't argue style. Argue readability. Use where when the signature becomes hard to scan.
Bounds on structs and impls
You can put trait bounds on structs and impls, not just functions. This changes when the check happens.
struct Container<T: Clone> {
// Clone bound ensures we can duplicate items if needed.
items: Vec<T>,
}
impl<T: Clone> Container<T> {
fn duplicate(&self) -> Container<T> {
// Clone is available because of the struct bound.
Container {
items: self.items.clone(),
}
}
}
Bounds on a struct apply to the struct definition. Every use of Container<T> requires T: Clone. Bounds on an impl apply only to that impl. You can have multiple impls with different bounds.
struct Container<T> {
items: Vec<T>,
}
// This impl only works when T implements Clone.
impl<T: Clone> Container<T> {
fn duplicate(&self) -> Container<T> {
Container {
items: self.items.clone(),
}
}
}
// This impl works for any T, even if it doesn't implement Clone.
impl<T> Container<T> {
fn len(&self) -> usize {
self.items.len()
}
}
Put bounds on the struct if every impl needs them. Put bounds on the impl if only some methods need them. This keeps your types flexible. Users can store non-cloneable types in the container and still call methods that don't require cloning. Scope your bounds to the narrowest possible surface.
Pitfalls and compiler errors
If you forget a bound, the compiler rejects you with E0277. It lists the trait that's missing and suggests adding it.
fn broken<T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
The error says the trait bound 'T: std::cmp::PartialOrd' is not satisfied. It points to the line where you used >. The fix is to add T: PartialOrd. The compiler is your ally here. It tells you exactly what contract you broke. Read the error. Add the bound. Move on.
A subtle pitfall involves PartialOrd versus Ord. PartialOrd allows types where comparison might be undefined. Floating-point NaN is the classic example. NaN > 5.0 is false. 5.0 > NaN is false. NaN == NaN is false. This breaks transitivity. Ord requires a total ordering. Every value must be comparable to every other value. f64 implements PartialOrd but not Ord.
If you write a sorting algorithm that assumes transitivity, you need Ord. Using PartialOrd when you need Ord can lead to subtle bugs where the sort produces inconsistent results. Pick Ord when you can. It's stricter and safer for algorithms that rely on a complete ordering.
Another convention to watch: derive macros. If you define a struct, you usually want to derive comparison traits.
#[derive(PartialEq, Eq, PartialOrd, Ord)]
struct Score {
points: i32,
}
This generates the trait implementations for you. It's the standard way to make your types work with generic functions that require bounds. Don't hand-write these traits unless you need custom logic. Derive them and focus on the domain logic.
Decision: when to use what
Use T: Trait inline when you have one or two simple bounds and the signature stays readable. Use where clauses when you have multiple bounds, complex bounds involving associated types, or the return type is already cluttered with lifetimes. Use PartialOrd when you only need basic comparison and your types might have undefined comparisons like floating-point NaN. Use Ord when you need a total ordering for sorting, binary search, or hash map keys. Reach for Clone when you need a new copy of a value and the type doesn't support cheap bitwise duplication. Reach for Copy when the type is small and can be duplicated by copying bits, like integers or references. Put bounds on the struct when every implementation needs them. Put bounds on the impl when only specific methods require the trait. Treat the bound as a contract. If the type doesn't fulfill the contract, the code shouldn't compile.