Rust Generics: Parameterized Types and Functions

Generics are Rust's mechanism for writing code that works with multiple types while maintaining strict type safety. Instead of duplicating logic for each type, you write the code once with type...

Key Insights

  • Generics enable code reuse without sacrificing type safety through compile-time monomorphization, which generates specialized versions of functions and types for each concrete type used
  • Trait bounds transform generics from abstract placeholders into constrained types with guaranteed capabilities, allowing you to write functions that work on any type implementing specific behaviors
  • Rust’s zero-cost abstraction guarantee means generic code performs identically to hand-written specialized code, making generics the preferred choice over runtime polymorphism in most scenarios

Introduction to Generics

Generics are Rust’s mechanism for writing code that works with multiple types while maintaining strict type safety. Instead of duplicating logic for each type, you write the code once with type parameters that get filled in at compile time.

Consider finding the maximum value in a list. Without generics, you’d need separate functions:

fn max_i32(list: &[i32]) -> &i32 {
    let mut largest = &list[0];
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn max_f64(list: &[f64]) -> &f64 {
    let mut largest = &list[0];
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

With generics, one function handles both:

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

This isn’t just about reducing code—it’s about creating abstractions that the compiler verifies at compile time, catching errors before runtime.

Generic Functions

Generic functions use type parameters enclosed in angle brackets. Convention dictates single uppercase letters for type parameters: T for a single type, T and U for two types, or more descriptive names like Key and Value when clarity demands it.

Here’s a generic swap function:

fn swap<T>(a: &mut T, b: &mut T) {
    std::mem::swap(a, b);
}

// Usage
let mut x = 5;
let mut y = 10;
swap(&mut x, &mut y);

let mut s1 = String::from("hello");
let mut s2 = String::from("world");
swap(&mut s1, &mut s2);

Functions can accept multiple type parameters:

fn create_pair<T, U>(first: T, second: U) -> (T, U) {
    (first, second)
}

let pair = create_pair(42, "answer");  // (i32, &str)
let other = create_pair(3.14, true);   // (f64, bool)

The compiler generates a separate version of each generic function for every concrete type combination you use. This process, called monomorphization, happens at compile time with zero runtime cost.

Generic Structs and Enums

Structs and enums benefit from generics when you need the same structure with different data types:

struct Point<T> {
    x: T,
    y: T,
}

let integer_point = Point { x: 5, y: 10 };
let float_point = Point { x: 1.0, y: 4.0 };

Both fields must be the same type here. For mixed types, use multiple parameters:

struct Point<T, U> {
    x: T,
    y: U,
}

let mixed = Point { x: 5, y: 4.0 };  // Point<i32, f64>

Rust’s standard library extensively uses generic enums. Option<T> wraps any value that might be absent:

enum Option<T> {
    Some(T),
    None,
}

Result<T, E> handles operations that might fail:

enum Result<T, E> {
    Ok(T),
    Err(E),
}

These types work with any concrete types you need, from primitives to complex custom structures.

Generic Methods and Implementations

Implementing methods on generic types requires declaring type parameters on the impl block:

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn new(x: T, y: T) -> Self {
        Point { x, y }
    }
    
    fn x(&self) -> &T {
        &self.x
    }
}

You can also implement methods only for specific concrete types:

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

This method only exists on Point<f32>, not Point<i32> or other variants.

Methods can introduce their own generic parameters beyond the struct’s:

impl<T> Point<T> {
    fn mixup<U>(self, other: Point<U>) -> Point<T, U> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

let p1 = Point { x: 5, y: 10 };
let p2 = Point { x: "Hello", y: 'c' };
let p3 = p1.mixup(p2);  // Point<i32, char>

Trait Bounds and Constraints

Type parameters without constraints are too abstract for most operations. You can’t compare, print, or clone generic values without guarantees about their capabilities. Trait bounds provide these guarantees.

The simplest bound syntax appears inline:

fn print_value<T: std::fmt::Display>(value: T) {
    println!("Value: {}", value);
}

This requires T to implement Display, enabling the {} formatter. Multiple bounds use the + syntax:

fn print_and_clone<T: std::fmt::Display + Clone>(value: T) -> T {
    println!("{}", value);
    value.clone()
}

Complex bounds become unwieldy inline. Use where clauses for readability:

fn complex_function<T, U>(t: T, u: U) -> i32
where
    T: std::fmt::Display + Clone,
    U: Clone + std::fmt::Debug,
{
    println!("{}", t);
    println!("{:?}", u);
    42
}

For comparisons, use PartialOrd:

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

Without the PartialOrd bound, the > operator wouldn’t compile—the compiler doesn’t know if T supports comparison.

Advanced Generic Patterns

Const generics allow type parameters for constant values, particularly useful for array sizes:

fn print_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) {
    for element in &arr {
        println!("{:?}", element);
    }
}

print_array([1, 2, 3]);
print_array([1, 2, 3, 4, 5]);

Before const generics, you’d need separate implementations for each array size or use slices.

Lifetimes often combine with generics when returning references:

fn longest<'a, T>(x: &'a [T], y: &'a [T]) -> &'a [T] {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

Associated types in traits provide another generic pattern. Instead of making the trait itself generic, you define a type within the trait:

trait Iterator {
    type Item;
    
    fn next(&mut self) -> Option<Self::Item>;
}

impl Iterator for Counter {
    type Item = u32;
    
    fn next(&mut self) -> Option<Self::Item> {
        // implementation
        None
    }
}

This differs from generic traits—each implementation can only have one associated type, while generic traits allow multiple implementations for different type parameters.

Best Practices and Common Pitfalls

Choose generics over trait objects (dyn Trait) when you know types at compile time. Generics incur no runtime cost:

// Generic - zero-cost abstraction
fn process_generic<T: Process>(item: T) {
    item.process();
}

// Trait object - runtime dispatch
fn process_dynamic(item: &dyn Process) {
    item.process();
}

Trait objects enable heterogeneous collections and dynamic dispatch, but come with pointer indirection and vtable lookups. Use them when you need runtime polymorphism.

When the compiler can’t infer types, use turbofish syntax (::<>):

let numbers = vec![1, 2, 3];
let collected: Vec<i32> = numbers.iter().map(|x| x * 2).collect();

// Or with turbofish
let collected = numbers.iter().map(|x| x * 2).collect::<Vec<i32>>();

Keep generic bounds minimal. Don’t require Clone if you only need Display. Overly restrictive bounds limit your function’s utility:

// Too restrictive
fn bad<T: Clone + Copy + Display + Debug>(value: T) { }

// Better - only require what you use
fn good<T: Display>(value: T) {
    println!("{}", value);
}

Remember that each concrete type instantiation increases binary size. A generic function used with ten types generates ten function copies in your binary. This is rarely a problem, but matters in size-constrained environments.

Generic code in Rust achieves the rare combination of abstraction, safety, and performance. Master generics, and you’ll write more reusable, maintainable code without compromising on Rust’s zero-cost guarantees.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.