4 min read
On this page

Generics

Generics let you write code that works across multiple types without duplicating logic. The Rust compiler turns generic code into specialized code for each concrete type used — a process called monomorphization. This means generics are zero-cost at runtime: you get the flexibility of polymorphism with the performance of hand-written specialized functions.

Generic Functions

A generic function declares type parameters in angle brackets:

fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    for item in &list[1..] {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn main() {
    let numbers = vec![34, 50, 25, 100, 65];
    println!("Largest number: {}", largest(&numbers));

    let chars = vec!['y', 'm', 'a', 'q'];
    println!("Largest char: {}", largest(&chars));
}
Largest number: 100
Largest char: y

The T: PartialOrd is a trait bound — it tells the compiler that T must support comparison. Without it, the > operator would not compile.

Generic Structs

Structs can be generic over one or more type parameters:

struct Pair<T, U> {
    first: T,
    second: U,
}

impl<T, U> Pair<T, U> {
    fn new(first: T, second: U) -> Self {
        Pair { first, second }
    }
}

// Methods only available when T implements Display
impl<T: std::fmt::Display, U: std::fmt::Display> Pair<T, U> {
    fn show(&self) {
        println!("({}, {})", self.first, self.second);
    }
}

fn main() {
    let p = Pair::new("hello", 42);
    p.show();
}
(hello, 42)

The second impl block demonstrates conditional method availability. show() only exists when both T and U implement Display. The compiler checks this at the call site, not the definition site.

Trait Bounds

Trait bounds constrain what a generic type must be capable of. You can combine multiple bounds with +:

use std::fmt::{Display, Debug};

fn log_and_return<T: Display + Clone + Debug>(value: &T) -> T {
    println!("[LOG] {:?}", value);
    value.clone()
}

fn main() {
    let name = String::from("production-server");
    let copy = log_and_return(&name);
    println!("Got: {}", copy);
}
[LOG] "production-server"
Got: production-server

Common bound combinations in real code:

// Hashable keys
fn count_occurrences<T: Eq + std::hash::Hash>(items: &[T]) -> std::collections::HashMap<&T, usize> {
    let mut counts = std::collections::HashMap::new();
    for item in items {
        *counts.entry(item).or_insert(0) += 1;
    }
    counts
}

// Serializable data
// T: Serialize + DeserializeOwned  (with serde)

// Error types
// E: std::error::Error + Send + Sync + 'static  (for anyhow compatibility)

Where Clauses

When trait bounds get long, move them to a where clause for readability:

use std::fmt::Display;

// This is hard to read
fn process<T: Display + Clone + Send + 'static, U: Display + Into<String>>(t: T, u: U) -> String {
    format!("{}: {}", t, u)
}

// This is much clearer
fn process_clean<T, U>(t: T, u: U) -> String
where
    T: Display + Clone + Send + 'static,
    U: Display + Into<String>,
{
    format!("{}: {}", t, u)
}

Where clauses can also express bounds that inline syntax cannot:

fn apply<F, T>(f: F, value: T) -> T
where
    F: Fn(T) -> T,
    T: Display,
{
    let result = f(value);
    println!("Result: {}", result);
    result
}

In practice, use where clauses whenever you have more than two bounds or more than two type parameters.

Monomorphization: Zero-Cost Generics

When you write a generic function, the compiler generates a separate, specialized version for each concrete type used. This is monomorphization:

fn double<T: std::ops::Mul<Output = T> + Copy>(x: T) -> T {
    x * x
}

fn main() {
    let a = double(3_i32);      // compiler generates double_i32
    let b = double(2.5_f64);    // compiler generates double_f64
    println!("{}, {}", a, b);
}
9, 6.25

After monomorphization, the generated code is identical to what you would write by hand for each type. There is no vtable lookup, no indirection, no runtime cost. The tradeoff is compile time and binary size — each instantiation produces its own machine code.

A Real-World Generic: Repository Pattern

Generics shine when building reusable abstractions over domain types:

use std::collections::HashMap;

trait Entity {
    type Id: Eq + std::hash::Hash + Clone;
    fn id(&self) -> &Self::Id;
}

struct InMemoryRepo<T: Entity> {
    store: HashMap<T::Id, T>,
}

impl<T: Entity> InMemoryRepo<T> {
    fn new() -> Self {
        InMemoryRepo {
            store: HashMap::new(),
        }
    }

    fn insert(&mut self, entity: T) {
        self.store.insert(entity.id().clone(), entity);
    }

    fn get(&self, id: &T::Id) -> Option<&T> {
        self.store.get(id)
    }

    fn count(&self) -> usize {
        self.store.len()
    }
}

#[derive(Debug)]
struct User {
    id: u64,
    name: String,
}

impl Entity for User {
    type Id = u64;
    fn id(&self) -> &u64 {
        &self.id
    }
}

fn main() {
    let mut repo = InMemoryRepo::new();
    repo.insert(User { id: 1, name: "Alice".into() });
    repo.insert(User { id: 2, name: "Bob".into() });
    println!("Users: {}", repo.count());
    if let Some(user) = repo.get(&1) {
        println!("Found: {:?}", user);
    }
}
Users: 2
Found: User { id: 1, name: "Alice" }

This InMemoryRepo works for any type implementing Entity. The associated type Id lets each entity define its own key type.

When Generics Make Sense vs When They Over-Complicate

Use generics when:

  • Multiple types share the same behavior and you want one implementation
  • You need zero-cost abstraction in a hot path
  • You are building a library that users will instantiate with their own types
  • The trait bounds naturally describe the required capabilities

Avoid generics when:

  • Only one or two concrete types will ever be used — just write concrete code
  • The trait bounds become a maze of where clauses that nobody can read
  • You are writing application code, not library code, and flexibility is not needed
  • Dynamic dispatch (dyn Trait) would be simpler and the performance difference is irrelevant

A common anti-pattern in Rust codebases is premature generalization: making a function generic over five type parameters when it will only ever be called with String. Write concrete code first. Generalize when you see real duplication.

Common Pitfalls

  • Bound explosion — adding bounds one at a time until the signature is unreadable. If you need T: Display + Debug + Clone + Send + Sync + 'static, consider whether a concrete type or a trait object would be simpler.
  • Forgetting Sized — all generic parameters are implicitly Sized. If you need to accept dynamically-sized types (like dyn Trait), add ?Sized: fn foo<T: ?Sized + Display>(t: &T).
  • Turbofish confusion — sometimes type inference fails and you need function::<ConcreteType>(arg). This is normal, not a design smell.
  • Generic return typesfn parse<T: FromStr>(s: &str) -> T requires the caller to specify T, either via turbofish or type annotation. Forgetting this leads to confusing "type annotations needed" errors.
  • Monomorphization bloat — in hot libraries, excessive generics can inflate binary size. Profile before worrying about this.

Key Takeaways

  • Generics let you write one function or struct that works for many types, with trait bounds specifying the required capabilities.
  • where clauses keep complex bounds readable. Use them liberally.
  • Monomorphization means generics are zero-cost at runtime — the compiler generates specialized code for each concrete type.
  • Write concrete code first. Generalize only when you see real duplication or are building a library API.
  • If trait bounds are becoming unwieldy, consider whether dyn Trait or a concrete type is the better tool.