Skip to main content

Generic Types in Rust

Published: October 23, 2025 Updated: August 28, 2026 Larry Qu 6 min read

Generics let you write code that works over many types without sacrificing performance or type safety. Instead of duplicating logic for i32, then f64, then String, you write it once with a type parameter T. The Rust compiler then generates the concrete versions at compile time — a process called monomorphization — so generics cost nothing at runtime.

Why Generics

Without generics, you’d write a separate max_i32, max_f64, and max_char function. With generics, one function handles all:

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

fn main() {
    println!("{}", largest(&[34, 50, 25, 100, 65]));    // 100
    println!("{}", largest(&['y', 'm', 'a', 'q']));     // y
    println!("{}", largest(&[3.14, 2.72, 1.41]));       // 3.14
}

The T: PartialOrd bound tells the compiler that T must support the > operator. Without it, the compiler would reject the item > largest comparison — it can’t know whether T supports ordering.

Generic Structs

// Single type parameter
#[derive(Debug)]
struct Point<T> {
    x: T,
    y: T,
}

// Two type parameters — x and y can differ
#[derive(Debug)]
struct Pair<T, U> {
    first: T,
    second: U,
}

fn main() {
    let int_point   = Point { x: 5, y: 10 };
    let float_point = Point { x: 1.5, y: 4.0 };

    let mixed = Pair { first: "hello", second: 42 };

    println!("{:?}", int_point);   // Point { x: 5, y: 10 }
    println!("{:?}", float_point);
    println!("{:?}", mixed);       // Pair { first: "hello", second: 42 }
}

Methods on Generic Structs

impl<T> methods are available for all T. You can also add impl blocks that only apply when T meets a bound:

use std::fmt::Display;

#[derive(Debug)]
struct Wrapper<T> {
    value: T,
    label: String,
}

impl<T> Wrapper<T> {
    fn new(value: T, label: &str) -> Self {
        Wrapper { value, label: label.to_string() }
    }

    fn value(&self) -> &T {
        &self.value
    }
}

// Only available when T implements Display
impl<T: Display> Wrapper<T> {
    fn print(&self) {
        println!("{}: {}", self.label, self.value);
    }
}

// Only available for Wrapper<f64> specifically
impl Wrapper<f64> {
    fn sqrt(&self) -> f64 {
        self.value.sqrt()
    }
}

fn main() {
    let w = Wrapper::new(3.14f64, "pi");
    w.print();          // pi: 3.14
    println!("{}", w.sqrt()); // 1.7724...

    let wi = Wrapper::new(42u32, "answer");
    wi.print();         // answer: 42
    // wi.sqrt();       // Error: not defined for Wrapper<u32>
}

Generic Enums

Option<T> and Result<T, E> are the most-used generic enums in all of Rust:

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

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

You can define your own:

#[derive(Debug)]
enum Either<L, R> {
    Left(L),
    Right(R),
}

impl<L: std::fmt::Display, R: std::fmt::Display> Either<L, R> {
    fn value_str(&self) -> String {
        match self {
            Either::Left(l)  => format!("Left({})", l),
            Either::Right(r) => format!("Right({})", r),
        }
    }
}

fn divide(a: f64, b: f64) -> Either<f64, &'static str> {
    if b == 0.0 {
        Either::Right("division by zero")
    } else {
        Either::Left(a / b)
    }
}

fn main() {
    println!("{}", divide(10.0, 2.0).value_str()); // Left(5)
    println!("{}", divide(1.0, 0.0).value_str());  // Right(division by zero)
}

Trait Bounds

Trait bounds constrain which types are allowed for a generic parameter. There are three syntaxes:

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

// Inline bound
fn print_all<T: Display + Debug>(items: &[T]) {
    for item in items {
        println!("{:?}{}", item, item);
    }
}

// `impl Trait` syntax (shorter for simple cases)
fn print_one(item: &impl Display) {
    println!("{}", item);
}

// `where` clause (cleaner for complex bounds)
fn complex_function<T, U, V>(t: T, u: U, v: V) -> String
where
    T: Display + Clone,
    U: Debug + PartialOrd,
    V: Into<String>,
{
    format!("{} {:?}", t, u)
}

Bounds on Associated Types

When a trait has an associated type, you can bound it:

use std::iter::Iterator;

fn sum_positive<I>(iter: I) -> i64
where
    I: Iterator<Item = i64>,
{
    iter.filter(|&x| x > 0).sum()
}

fn main() {
    let v = vec![-3i64, 1, -1, 4, -1, 5, 9, -2, 6];
    println!("{}", sum_positive(v.into_iter())); // 25
}

Generic Functions Returning References: Lifetimes

When a generic function returns a reference, you often need lifetime annotations to tell the compiler how inputs and outputs relate:

// The returned reference lives at least as long as both inputs
fn longest<'a>(s1: &'a str, s2: &'a str) -> &'a str {
    if s1.len() >= s2.len() { s1 } else { s2 }
}

fn main() {
    let s1 = String::from("long string");
    let result;
    {
        let s2 = String::from("xyz");
        result = longest(s1.as_str(), s2.as_str());
        println!("Longest: {}", result);
    }
}

A Generic Stack Implementation

A practical example showing generics in action:

#[derive(Debug)]
struct Stack<T> {
    elements: Vec<T>,
}

impl<T> Stack<T> {
    fn new() -> Self {
        Stack { elements: Vec::new() }
    }

    fn push(&mut self, item: T) {
        self.elements.push(item);
    }

    fn pop(&mut self) -> Option<T> {
        self.elements.pop()
    }

    fn peek(&self) -> Option<&T> {
        self.elements.last()
    }

    fn is_empty(&self) -> bool {
        self.elements.is_empty()
    }

    fn size(&self) -> usize {
        self.elements.len()
    }
}

// Additional method only when T: Display
impl<T: std::fmt::Display> Stack<T> {
    fn print_all(&self) {
        for (i, item) in self.elements.iter().enumerate() {
            println!("[{}] {}", i, item);
        }
    }
}

fn main() {
    let mut stack: Stack<i32> = Stack::new();
    stack.push(1);
    stack.push(2);
    stack.push(3);

    println!("Top: {:?}", stack.peek());  // Some(3)
    println!("Size: {}", stack.size());   // 3
    stack.print_all();

    while let Some(val) = stack.pop() {
        println!("Popped: {}", val);
    }
}

Generic Type Aliases

Type aliases simplify complex generic types:

use std::collections::HashMap;

type Registry<T> = HashMap<String, Vec<T>>;
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;

fn load_registry() -> Result<Registry<u32>> {
    let mut r: Registry<u32> = HashMap::new();
    r.insert("admin".to_string(), vec![1, 2, 3]);
    Ok(r)
}

Const Generics

Rust also supports const generics — type parameters that are values, not types. This is how fixed-size arrays work:

// A fixed-size buffer generic over its capacity
struct Buffer<const N: usize> {
    data: [u8; N],
    len: usize,
}

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

    fn push(&mut self, byte: u8) -> bool {
        if self.len < N {
            self.data[self.len] = byte;
            self.len += 1;
            true
        } else {
            false // Buffer full
        }
    }

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

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

fn main() {
    let mut buf: Buffer<4> = Buffer::new();
    buf.push(1); buf.push(2); buf.push(3);
    println!("{:?}", buf.as_slice()); // [1, 2, 3]
    println!("capacity: {}", buf.capacity()); // 4
}

This is used extensively in embedded Rust where heap allocation is unavailable.

Monomorphization — Zero-Cost Generics

The compiler generates a separate, specialized copy of every generic function for each concrete type it’s called with:

// You write:
fn identity<T>(x: T) -> T { x }

// Compiler generates (conceptually):
fn identity_i32(x: i32) -> i32 { x }
fn identity_str(x: &str) -> &str { x }
fn identity_string(x: String) -> String { x }

This means:

  • Generic code runs at full speed — no boxing, no virtual dispatch
  • Binary size increases proportionally to the number of distinct type instantiations
  • Compilation time increases with more instantiations

When binary size matters (embedded, WASM), use dyn Trait instead of generics for rarely-called code paths.

When to Use Generics vs Trait Objects

Generics (<T: Trait>) Trait objects (dyn Trait)
Dispatch Static, compile-time Dynamic, runtime vtable
Performance Zero overhead Small indirection cost
Mixed types in collection No Yes
Binary size Larger (monomorphization) Smaller (single impl)
Best for Performance-critical paths Flexibility, heterogeneous data

Summary

  • Generic functions, structs, and enums work over multiple types without code duplication
  • Trait bounds (T: Trait) constrain which types are valid
  • where clauses improve readability for complex bounds
  • Const generics allow size/value parameters in addition to type parameters
  • Monomorphization makes generics zero-cost — identical performance to hand-written specific code
  • Generics add binary size; use dyn Trait when that matters more than raw speed

Resources

Comments

👍 Was this article helpful?