13. Generics
📋 Jump to Takeaways🎁 What if one function signature could accept an integer, a string, or a type nobody has invented yet, and the compiler still checked every call for correctness?
Traits describe what a type can do. Generics let you write code that works across many types at once, with a type variable standing in for "some type we'll pin down later." The best part: it costs nothing at runtime.
Generic Functions
A generic function works with any type at all, no trait required, as long as the body doesn't try to do anything type-specific. T just stands in for "some type," decided by whatever you pass in when you call it.
fn first<T>(a: T, b: T) -> T {
a
}
fn main() {
println!("{}", first(1, 2)); // 1
println!("{}", first("cat", "dog")); // cat
}No bounds here because first never inspects or operates on a and b, it just hands one back. Try to print, compare, or add T, though, and the compiler stops you, it has no idea whether the type passed in supports that.
fn show<T>(item: T) {
println!("{}", item); // ❌ error: T doesn't implement Display
}T could be anything, including types with no Display implementation, so the compiler refuses to compile this at all, it won't wait until runtime to find out. Add the bound (T: Display) and the error goes away. That's what trait bounds are for.
Trait Bounds
In the traits lesson you passed &impl Trait. That's shorthand. The fuller form is a trait bound: <T: Trait>. Spelling it out gives you more control.
use std::fmt::Display;
// <T: Display> means "T can be any type that implements Display"
fn announce<T: Display>(item: &T) {
println!("Breaking: {}", item);
}
// Two parameters of the SAME type T:
fn compare<T: Display>(a: &T, b: &T) {
println!("{} vs {}", a, b);
}
fn main() {
announce(&42); // Breaking: 42
compare(&"cats", &"dogs"); // cats vs dogs
// Why &T matters: borrowing means we keep ownership
let name = String::from("Alice");
let score = 95;
announce(&name); // borrows name — we can still use it below
announce(&score); // borrows score (for i32, passing by value is faster — but &T keeps the API consistent for all types)
println!("Still mine: {} {}", name, score); // ✅ both still usable
}We use &T (borrow) instead of T (move) so the caller keeps their values. With T, passing name would move it into the function and you couldn't use it after.
What's the difference from &impl Trait? Compare these two:
// impl Trait: a and b can be DIFFERENT types (both just need Display)
fn print_two(a: &impl Display, b: &impl Display) {
println!("{} {}", a, b);
}
print_two(&42, &"hello"); // ✅ i32 and &str — different types, both Display
// Generic T: a and b must be the SAME type
fn print_same<T: Display>(a: &T, b: &T) {
println!("{} {}", a, b);
}
print_same(&42, &100); // ✅ both i32
print_same(&42, &"hello"); // ❌ error: expected i32, got &strUse <T> when the parameters must match (comparing, sorting, returning one of them). Use impl Trait when you don't care if they're different.
Multiple Bounds with +
One bound not enough? Require several at once with +.
use std::fmt::{Display, Debug};
fn print_both<T: Display + Debug>(item: &T) {
println!("Display: {}", item); // Uses Display
println!("Debug: {:?}", item); // Uses Debug
}
fn main() {
print_both(&42);
// Display: 42
// Debug: 42
}The + syntax says "this type must implement Display AND Debug." You can stack as many bounds as needed.
Where Clauses
Once you have three or four bounds, the signature turns into soup. A where clause pulls them out where you can actually read them.
use std::fmt::{Display, Debug};
fn complex_function<T, U>(t: &T, u: &U) -> String
where
T: Display + Clone,
U: Debug,
{
format!("{} and {:?}", t, u)
}
fn main() {
let result = complex_function(&"hello", &42);
println!("{}", result); // hello and 42
}Same constraints, cleaner signature. Reach for where once you have more than a bound or two.
Generic Structs and Enums
Generics aren't just for functions. Your own types can hold any type too. You parameterize a struct or enum with a type variable, and one definition works with all of them.
#[derive(Debug)]
struct Pair<T> {
first: T,
second: T,
}Now let's add a method that only makes sense for types you can compare. PartialOrd lets you compare values with <, >, <=, >=, just like PartialEq enables ==.
The syntax for implementing methods on a generic struct has two <T> parts, and it trips people up:
impl<T: PartialOrd + std::fmt::Display> Pair<T> {
// ^^^^ declares T and its bounds ^^^^ applies this impl to Pair<T>The first <T: ...> after impl says "I'm introducing a type variable called T with these bounds." The second <T> after Pair says "this impl block is for Pair<T>." You need both.
// std::fmt::Display is the full path — you can also `use std::fmt::Display` and write just `Display`
impl<T: PartialOrd + std::fmt::Display> Pair<T> {
fn larger(&self) -> &T {
if self.first >= self.second {
&self.first
} else {
&self.second
}
}
}
fn main() {
let int_pair = Pair { first: 5, second: 10 };
println!("Larger: {}", int_pair.larger()); // Larger: 10
let str_pair = Pair { first: "apple", second: "banana" };
println!("Larger: {}", str_pair.larger()); // Larger: banana
}This is a conditional impl: larger() only exists on Pair<T> when T satisfies both bounds. You can still make a Pair out of a type that doesn't implement PartialOrd, that part of the struct definition has no bounds at all. You just won't be able to call .larger() on it, the compiler rejects that call specifically, not the whole Pair type.
Three Kinds of impl
You'll see impl used three different ways in Rust. Here they are side by side:
use std::fmt;
struct Pair<T> { first: T, second: T }
trait Summary {
fn summarize(&self) -> String;
}
// 1. impl Type — add methods to your type (no trait involved)
impl<T> Pair<T> {
fn new(first: T, second: T) -> Self {
Pair { first, second }
}
}
// 2. impl Trait for Type — implement a trait (like satisfying a Go interface, but explicit)
impl<T: fmt::Debug> Summary for Pair<T> {
fn summarize(&self) -> String {
format!("Pair({:?}, {:?})", self.first, self.second)
}
}
// 3. impl<T: Bounds> Type<T> — conditional methods (only when T satisfies bounds)
impl<T: PartialOrd> Pair<T> {
fn larger(&self) -> &T {
if self.first >= self.second { &self.first } else { &self.second }
}
}
fn main() {
let p = Pair::new(5, 10);
println!("{}", p.summarize()); // Pair(5, 10) — from impl Summary for Pair
println!("{}", p.larger()); // 10 — from conditional impl
}| Syntax | What it does | Go equivalent |
|---|---|---|
impl Pair |
Add methods to Pair | func (p Pair) Method() |
impl Trait for Type |
Type implements Trait | Type satisfies an interface |
impl<T: Ord> Pair<T> |
Methods only exist when T is Ord | No equivalent in Go |
The third one is unique to Rust. You can have the same struct with different method sets depending on what T is. A Pair<Vec<i32>> has new() and fmt(), but not larger(), because Vec doesn't implement PartialOrd.
You've been using generic enums all along. Option<T> and Result<T, E> are exactly this pattern:
// The standard library defines them roughly like this:
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}So how is this free at runtime? The compiler generates a specialized version of your code for each concrete type you actually use. That step is called monomorphization:
// You write this once:
fn first<T>(a: T, b: T) -> T { a }
first(1, 2); // used with i32
first("cat", "dog"); // used with &str
// Compiler generates two separate functions:
// fn first_i32(a: i32, b: i32) -> i32 { a }
// fn first_str(a: &str, b: &str) -> &str { a }You write one generic Pair<T>, and the compiler stamps out a Pair<i32> and a Pair<&str> as if you'd hand-written both. Flexibility of generics, speed of hand-written code. The tradeoff: larger binaries (one copy per type used), but zero runtime cost.
Operator Overloading with Add
Trait bounds aren't just for Display and Debug. The + operator itself is backed by a trait, std::ops::Add, so you can write one function that adds integers, floats, or even Strings.
use std::ops::Add;
fn add<T: Add<Output = T>>(a: T, b: T) -> T {
a + b
}
fn main() {
println!("{}", add(2, 3)); // 5
println!("{}", add(2.5, 1.5)); // 4
println!("{}", add(String::from("foo"), String::from("bar"))); // foobar
}Add<Output = T> means "T implements +, and the result comes back as a T too." That Output piece is an associated type: a type that belongs to the trait itself, not a generic parameter. Think of it as a trait saying "when you implement me, you also tell me what type you produce."
You'll see associated types everywhere: Iterator has type Item (what .next() yields), Add has type Output (what + returns), FromStr has type Err. They're like a contract: "implement this trait, and also declare these types."
i32, f64, and String all implement Add, so the same generic function covers all three.
String literals (&str) don't implement Add the way String does, so add("foo", "bar") won't compile. Wrap them in String::from(...) first.
add() above uses +, it needs a type that already implements Add. To give your own struct a + operator, implement Add on it directly.
struct Point<T> {
x: T,
y: T,
}
impl<T: Add<Output = T>> Add for Point<T> {
type Output = Point<T>; // "Point + Point produces a Point"
fn add(self, other: Point<T>) -> Point<T> {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
fn main() {
let a = Point { x: 1, y: 2 };
let b = Point { x: 3, y: 4 };
let c = a + b;
println!("{} {}", c.x, c.y); // 4 6
let f1 = Point { x: 1.5, y: 2.5 };
let f2 = Point { x: 0.5, y: 1.5 };
let f3 = f1 + f2;
println!("{} {}", f3.x, f3.y); // 2 4
}type Output = Point<T> declares what a + b produces, and fn add is the method the trait requires. You never call .add() by name, a + b compiles down to Add::add(a, b) automatically. That's the difference from the earlier add() function: this add method is how + gets defined for a type in the first place, not a way of calling it.
Key Takeaways
- A generic function
fn first<T>(...)works with any type, no bounds needed, as long as the body doesn't do anything type-specific - A trait bound
<T: Trait>constrains a generic type to ones that implementTrait <T: Trait>forces one shared type;&impl Traitallows a different type per parameter- Use
+to require multiple bounds, and awhereclause when the signature gets busy - Structs and enums can be generic (
Pair<T>), so one definition works with many types - A conditional impl (
impl<T: Bound> Pair<T>) only adds its methods whenTsatisfies the bound, other instantiations of the struct still exist, just without that method Option<T>andResult<T, E>are generic enums you already use- Monomorphization compiles generics into specialized code, so there's zero runtime cost
- Operators like
+are backed by traits (std::ops::Add), soT: Add<Output = T>lets one function add integers, floats, orStrings - Requiring
Add(T: Add<Output = T>) uses an existing+; implementingAdd for TypeAgives your own struct a+operator, called automatically asa + b, never as.add()by name
🎁 Now that you understand traits and generics, remember how Result only handled one error type at a time? What if a function can fail in two completely different ways, and callers need to know which one happened? Next up: custom error types, trait objects, and the crates that make it all painless.