Skip to main content
Rust · lesson 15 of 30
Rust intermediate Lesson 15 of 30

Generics in Rust

Write generic Rust functions, structs and enums: trait bounds, where clauses, const generics, blanket impls, and monomorphization, with real output.

By SkillByExample · Updated

Generic Functions

Without generics, you need a separate function for every type you want to support. With generics, you write the logic once and let the compiler generate the type-specific versions for you. The type parameter <T> is a placeholder that gets filled in at each call site, and trait bounds like T: PartialOrd constrain which types are allowed so your function can rely on specific operations.

// Without generics — only works for i32
fn largest_i32(list: &[i32]) -> &i32 {
    let mut largest = &list[0];
    for item in list {
        if item > largest { largest = item; }
    }
    largest
}

// With generics — works for any type that supports comparison
fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    for item in list {
        if item > largest { largest = item; }
    }
    largest
}

fn main() {
    let ints   = vec![34, 50, 25, 100, 65];
    let floats = vec![1.5, 9.1, 2.3, 0.8];
    let chars  = vec!['y', 'm', 'a', 'q'];

    println!("{}", largest_i32(&ints));
    println!("{}", largest(&ints));
    println!("{}", largest(&floats));
    println!("{}", largest(&chars));
}
$ rustc generics_generic_functions.rs && ./generics_generic_functions
100
100
9.1
y

largest_i32 and largest::<i32> agree on 100, but only the generic version also handles f64 and char — largest_i32(&floats) would be a type error. Note largest panics on an empty slice (&list[0]); returning Option<&T> is the robust version.

Generic Structs

Structs can be generic over one or more type parameters, making them reusable data containers. You can also add impl blocks with extra bounds that make certain methods available only when the type parameter satisfies additional constraints — so a method like display_largest is only callable when T implements Display.

#[derive(Debug)]
struct Pair<T> {
    first: T,
    second: T,
}

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

    fn swap(self) -> Pair<T> {
        Pair { first: self.second, second: self.first }
    }
}

// Conditional method — only available when T: Display + PartialOrd
impl<T: std::fmt::Display + PartialOrd> Pair<T> {
    fn display_largest(&self) {
        if self.first >= self.second {
            println!("largest: {}", self.first);
        } else {
            println!("largest: {}", self.second);
        }
    }
}

fn main() {
    let p = Pair::new(5, 10);
    p.display_largest();
    let swapped = p.swap();
    println!("{:?}", swapped);

    let s = Pair::new("hello", "world");
    s.display_largest();
}
$ rustc generics_generic_structs.rs && ./generics_generic_structs
largest: 10
Pair { first: 10, second: 5 }
largest: world

Multiple Generic Parameters

A struct or function can have as many type parameters as needed. This is how the standard library’s HashMap<K, V> and Result<T, E> work — each parameter is independently constrained and serves a distinct role. The where clause becomes especially valuable here to keep the signature readable.

#[derive(Debug)]
struct KeyValue<K, V> {
    key: K,
    value: V,
}

impl<K: std::fmt::Display, V: std::fmt::Display> KeyValue<K, V> {
    fn new(key: K, value: V) -> Self { Self { key, value } }

    fn display(&self) {
        println!("{} = {}", self.key, self.value);
    }
}

// zip_with combines two vectors element-by-element using a closure
fn zip_with<A, B, C, F>(a: Vec<A>, b: Vec<B>, f: F) -> Vec<C>
where
    F: Fn(A, B) -> C,  // where clause keeps the signature clean
{
    a.into_iter().zip(b.into_iter()).map(|(x, y)| f(x, y)).collect()
}

fn main() {
    let kv = KeyValue::new("name", "Alice");
    kv.display();

    let sums = zip_with(vec![1, 2, 3], vec![10, 20, 30], |a, b| a + b);
    println!("{:?}", sums);
}
$ rustc generics_multiple_generic_parameters.rs && ./generics_multiple_generic_parameters
name = Alice
[11, 22, 33]

where Clauses

where clauses move bounds out of the angle brackets so the signature stays readable, and they are the natural place for bounds on associated types such as I::Item. As a rule of thumb, use inline bounds for simple single-bound cases and where for anything with two or more bounds per parameter, or associated type constraints.

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

// Hard to read inline — bounds crowd the function name off screen
fn debug_display_inline<T: Debug + Display, U: Debug>(t: T, u: U) {
    println!("{} {:?}", t, u);
}

// Easier to read with where — bounds are separated from the signature
fn debug_display<T, U>(t: T, u: U)
where
    T: Debug + Display,
    U: Debug,
{
    println!("{} {:?}", t, u);
}

// where reads best for bounds on associated types
fn process<I>(iter: I)
where
    I: Iterator,
    I::Item: Display,  // a bound on the iterator's associated Item type
{
    for item in iter {
        println!("{}", item);
    }
}

fn main() {
    debug_display_inline("inline", Some(1));
    debug_display(42, vec![1, 2, 3]);
    process(vec!["a", "b", "c"].into_iter());
}
$ rustc generics_where_clauses.rs && ./generics_where_clauses
inline Some(1)
42 [1, 2, 3]
a
b
c

debug_display_inline and debug_display are the same function with the bounds written in two places — the compiler treats them identically. Since Rust 1.79 the associated-type bound can also be written inline as I: Iterator<Item: Display>; where I::Item: Display works on every Rust version.

Generic Enums

Enums can be generic too. You have already used the two most important ones — Option<T> represents a value that may or may not exist, and Result<T, E> represents a computation that may succeed or fail. Writing your own generic enum follows the same pattern and is useful for recursive data structures like trees where you need the type to refer to itself.

// From the standard library:
// enum Option<T> { Some(T), None }
// enum Result<T, E> { Ok(T), Err(E) }

// A binary tree generic over any node value type
#[derive(Debug)]
enum Tree<T> {
    Leaf(T),
    Node(Box<Tree<T>>, Box<Tree<T>>),
}

impl<T: std::fmt::Display> Tree<T> {
    fn depth(&self) -> usize {
        match self {
            Tree::Leaf(_) => 0,
            // Recursion works because Box<Tree<T>> is a known-size pointer
            Tree::Node(left, right) => 1 + left.depth().max(right.depth()),
        }
    }
}

fn main() {
    let tree = Tree::Node(
        Box::new(Tree::Node(
            Box::new(Tree::Leaf(1)),
            Box::new(Tree::Leaf(2)),
        )),
        Box::new(Tree::Leaf(3)),
    );
    println!("depth: {}", tree.depth());
}
$ rustc generics_generic_enums.rs && ./generics_generic_enums
depth: 2

Const Generics

Since Rust 1.51, constant values like array lengths can be generic parameters. This lets you write functions and types that are parameterized by a size known at compile time, enabling stack-allocated fixed-size buffers with no heap allocation and no runtime bounds checking overhead.

// Works for arrays of any length — the length is a compile-time constant
fn sum_array<const N: usize>(arr: [i32; N]) -> i32 {
    arr.iter().sum()
}

// A fixed-size buffer where SIZE is determined by the caller at compile time
struct FixedBuffer<const SIZE: usize> {
    data: [u8; SIZE],
    len: usize,
}

impl<const SIZE: usize> FixedBuffer<SIZE> {
    fn new() -> Self {
        Self { data: [0u8; SIZE], len: 0 }
    }

    fn capacity(&self) -> usize { SIZE }

    fn push(&mut self, byte: u8) -> bool {
        if self.len == SIZE { return false; } // full — no reallocation possible
        self.data[self.len] = byte;
        self.len += 1;
        true
    }

    fn contents(&self) -> &[u8] { &self.data[..self.len] }
}

fn main() {
    println!("{}", sum_array([1, 2, 3, 4, 5]));
    println!("{}", sum_array([10, 20]));

    let mut buf = FixedBuffer::<2>::new();
    println!("capacity: {}", buf.capacity());
    for b in [7, 8, 9] {
        println!("push {}: {}", b, buf.push(b));
    }
    println!("contents: {:?}", buf.contents());
}
$ rustc generics_const_generics.rs && ./generics_const_generics
15
30
capacity: 2
push 7: true
push 8: true
push 9: false
contents: [7, 8]

FixedBuffer::<2> and FixedBuffer::<1024> are different types, each with its capacity baked in; the third push returns false because a fixed array cannot grow. sum_array([10, 20]) and sum_array([1, 2, 3, 4, 5]) likewise instantiate two copies of sum_array, one for N = 2 and one for N = 5.

Monomorphization — Zero-Cost Generics

The reason generics in Rust have no runtime overhead is monomorphization: the compiler reads each use site of a generic function or struct, notes the concrete types involved, and emits a separate specialized copy of the machine code for each combination. The resulting binary contains code equivalent to what you would have written by hand for each specific type. The trade-off is longer compile times and potentially larger binaries when a generic is instantiated with many different types.

fn add<T: std::ops::Add<Output = T>>(a: T, b: T) -> T {
    a + b
}

fn main() {
    // The compiler generates two separate functions in the binary:
    println!("{}", add(1_i32, 2_i32));     // effectively fn add_i32(a: i32, b: i32) -> i32
    println!("{}", add(1.0_f64, 2.5_f64)); // effectively fn add_f64(a: f64, b: f64) -> f64

    // Each instantiation is its own function item with its own type
    println!("{}", std::any::type_name_of_val(&add::<i32>));
    println!("{}", std::any::type_name_of_val(&add::<f64>));
    // No boxing, no vtable, no indirection — same speed as hand-written code
}
$ rustc generics_monomorphization_zero_cost_generics.rs && ./generics_monomorphization_zero_cost_generics
3
3.5
generics_monomorphization_zero_cost_generics::add<i32>
generics_monomorphization_zero_cost_generics::add<f64>

type_name_of_val shows that add::<i32> and add::<f64> are two distinct function items, each prefixed with the crate name (here the file name). It reports types, not machine code, but each distinct instantiation that is called is compiled separately — that is monomorphization.

Blanket Implementations

A blanket implementation applies a trait to every type that satisfies a given bound, rather than to one specific type. This is how the standard library propagates behavior automatically — impl<T: Display> ToString for T is why every type that implements Display also gets a .to_string() method for free. You can use the same technique to extend your own traits.

use std::fmt::Display;

trait Printable {
    fn print(&self);
}

// Blanket impl: every type that implements Display also gets Printable
// This one impl covers i32, f64, &str, String, and any future Display type
impl<T: Display> Printable for T {
    fn print(&self) {
        println!("{}", self);
    }
}

fn main() {
    42.print();
    "hello".print();
    3.14_f64.print();
}
$ rustc generics_blanket_implementations.rs && ./generics_blanket_implementations
42
hello
3.14

Type Inference with Generics

Rust’s type inference works across generic boundaries. The compiler tracks how values are used and resolves type parameters without you needing to spell them out. You only need explicit annotations when the compiler has multiple valid choices and cannot determine which one you intend — such as an empty collection or a method that could return different types.

fn main() {
    // Type inferred from the push calls — no annotation needed
    let mut v = Vec::new();
    v.push(1_i32);
    v.push(2);

    // Annotation required when inference doesn't have enough information
    let empty: Vec<String> = Vec::new();
    let parsed = "42".parse::<i32>().unwrap();   // turbofish syntax
    let collected = (0..5).collect::<Vec<_>>();  // _ lets compiler infer element type

    println!("{:?} {:?} {} {:?}", v, empty, parsed + 1, collected);
}
$ rustc generics_type_inference_with_generics.rs && ./generics_type_inference_with_generics
[1, 2] [] 43 [0, 1, 2, 3, 4]

v became Vec<i32> from its first push. empty would have been an error without its annotation — nothing in the program says what it holds — and parse needs the turbofish because str::parse can produce dozens of types.

Practice

  1. Call largest(&empty) with let empty: Vec<i32> = Vec::new();. What happens? Then rewrite largest to return Option<&T>.

    Solution

    It compiles, then panics at runtime:

    thread 'main' (7012) panicked at generics_empty.rs:2:24:
    index out of bounds: the len is 0 but the index is 0

    An Option-returning version handles the empty case with ? on the first element:

    fn largest<T: PartialOrd>(list: &[T]) -> Option<&T> {
        let mut iter = list.iter();
        let mut largest = iter.next()?;
        for item in iter {
            if item > largest { largest = item; }
        }
        Some(largest)
    }

    largest(&empty) is now None and largest(&[3, 9, 4]) is Some(9).

  2. Build Pair { first: vec![1], second: vec![2] } and call display_largest() on it. Why is the method “not found” when it plainly exists?

    Solution
    error[E0599]: the method `display_largest` exists for struct `Pair<Vec<{integer}>>`, but its trait bounds were not satisfied
    ...
    note: trait bound `Vec<{integer}>: std::fmt::Display` was not satisfied

    Vec is PartialOrd but not Display, and the method lives in an impl block that requires both. new and swap would still work — they’re in the unbounded impl<T> Pair<T> block.

  3. Rewrite process with the bound inline: fn process<I: Iterator<Item: Display>>(iter: I). Does it compile?

    Solution

    Yes, on Rust 1.79 or newer — process(vec![1.5, 2.5].into_iter()) prints 1.5 and 2.5. This “associated type bound” syntax is equivalent to the where I::Item: Display version.

  4. Call sum_array(vec![1, 2, 3]). Why doesn’t a const-generic array parameter accept a Vec?

    Solution
    error[E0308]: mismatched types
    ...
      = note: expected array `[i32; _]`
                found struct `Vec<{integer}>`

    N must be known at compile time, and a Vec’s length is only known at runtime. Take a slice (&[i32]) if you need to accept both.

Frequently Asked Questions

What is monomorphization?
Monomorphization is the process by which the compiler generates a concrete version of a generic function or struct for each type it is used with. It produces zero-cost abstractions — in optimized builds, generic code runs as fast as hand-written type-specific code, with no dispatch overhead.
What is a where clause?
A where clause lists trait bounds after the function signature. It is equivalent to inline bounds but is easier to read when there are many bounds or complex types.
Can a generic type have multiple trait bounds?
Yes. Use + to combine bounds: T: Display + Clone + PartialOrd.