Generics in Rust
Generics let you write code that works with many different types without repeating yourself.
Instead of writing a function that finds the largest i32, then another for f64, then another for char,
you write one function parameterized over a type T — and the compiler figures out the rest.
The Problem Generics Solve
Imagine you want to find the largest element in a list. Without generics you'd need a separate function for every type you care about:
fn largest_i32(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest { largest = item; }
}
largest
}
fn largest_f64(list: &[f64]) -> &f64 {
let mut largest = &list[0];
for item in list {
if item > largest { largest = item; }
}
largest
}
// ... and so on for every typeThe logic is identical — only the type changes. Generics let you write it once.
Generic Functions
Declare a type parameter between angle brackets after the function name. The bound PartialOrd
tells the compiler that T supports the > operator:
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 numbers = vec![34, 50, 25, 100, 65];
println!("Largest number: {}", largest(&numbers));
let chars = vec!['y', 'm', 'a', 'q'];
println!("Largest char: {}", largest(&chars));
}Generic Structs
Structs can hold values of a generic type. Declare the type parameter after the struct name:
struct Pair<T> {
first: T,
second: T,
}
fn main() {
let int_pair = Pair { first: 5, second: 10 };
let str_pair = Pair { first: "hello", second: "world" };
println!("{} {}", int_pair.first, int_pair.second);
println!("{} {}", str_pair.first, str_pair.second);
}Generic Enums
The standard library's most famous generic enums are Option and Result. Their definitions
look exactly like user-defined generics:
// How Option<T> is defined in the standard library
enum Option<T> {
Some(T),
None,
}
// How Result<T, E> is defined
enum Result<T, E> {
Ok(T),
Err(E),
}
// Using them
fn divide(a: f64, b: f64) -> Option<f64> {
if b == 0.0 { None } else { Some(a / b) }
}
fn read_number(s: &str) -> Result<i32, std::num::ParseIntError> {
s.trim().parse()
}Result<T, E> uses two type parameters — one for the success value, one for the error.
This is a common pattern whenever an operation can fail in structured ways.
impl Blocks for Generic Structs
When writing methods for a generic struct, repeat the type parameter on the impl keyword
so the compiler knows it is a generic parameter and not a concrete type named T:
struct Pair<T> {
first: T,
second: T,
}
// impl<T> means "for any type T"
impl<T> Pair<T> {
fn new(first: T, second: T) -> Self {
Pair { first, second }
}
fn first(&self) -> &T {
&self.first
}
}
fn main() {
let p = Pair::new(3, 7);
println!("First: {}", p.first());
}Conditional impl Blocks
You can add methods only when the type parameter satisfies extra traits. The method
compare_display below is available only when T implements both Display and PartialOrd:
use std::fmt::Display;
impl<T: Display + PartialOrd> Pair<T> {
fn compare_display(&self) {
if self.first >= self.second {
println!("First ({}) is larger or equal.", self.first);
} else {
println!("Second ({}) is larger.", self.second);
}
}
}
fn main() {
let p = Pair::new(10, 20);
p.compare_display(); // works because i32: Display + PartialOrd
}Monomorphization — Zero Overhead
Rust generics have zero runtime cost. At compile time the compiler performs monomorphization: it replaces every generic instantiation with concrete code, as if you had written the specialized function by hand.
// You write this once:
fn identity<T>(x: T) -> T { x }
// The compiler generates (conceptually):
fn identity_i32(x: i32) -> i32 { x }
fn identity_str(x: &str) -> &str { x }
// ... one concrete copy per distinct type used in your programMultiple Type Parameters
Functions and structs can have as many type parameters as needed. A zip function that
combines two values of independent types:
fn zip<A, B>(a: A, b: B) -> (A, B) {
(a, b)
}
fn main() {
let pair = zip(42, "hello");
println!("{} {}", pair.0, pair.1);
let pair2 = zip(3.14, true);
println!("{} {}", pair2.0, pair2.1);
}Bounds on Struct Definitions vs impl Blocks
There is a subtle but important distinction: bounds placed on the struct definition are enforced whenever the struct is used, while bounds on impl blocks only apply to the methods in that block.
Rust's convention (and the compiler's preference) is to put bounds on impl blocks rather than struct definitions, unless the struct truly cannot be stored without the bound:
use std::fmt::Display;
// Bound on struct — every use of WrapperStrict<T> requires T: Display
struct WrapperStrict<T: Display> {
value: T,
}
// Preferred: no bound on struct, bound only on the impl block that needs it
struct Wrapper<T> {
value: T,
}
impl<T: Display> Wrapper<T> {
fn show(&self) {
println!("{}", self.value);
}
}
// You can still create Wrapper<T> for types that don't implement Display
// as long as you don't call show() on themT: Trait vs impl Trait Syntax
There are two ways to express "this parameter implements a trait." They look different but compile to the same thing for simple single-parameter cases:
use std::fmt::Display;
// Angle bracket / generic parameter form
fn print_generic<T: Display>(value: T) {
println!("{}", value);
}
// impl Trait form (shorthand, introduced in Rust 2018)
fn print_impl(value: impl Display) {
println!("{}", value);
}
// The generic form is required when T appears more than once
fn pair_generic<T: Display>(a: T, b: T) {
println!("{} and {}", a, b);
}
// This does NOT enforce a == b type — each can be a different Display type
fn pair_wrong(a: impl Display, b: impl Display) {
println!("{} and {}", a, b);
}Where Clauses for Complex Bounds
When bounds grow long, move them to a where clause after the return type for readability:
use std::fmt::{Debug, Display};
// Hard to read with everything inline
fn verbose<T: Clone + Debug + Display, U: Clone + Debug>(t: T, u: U) -> String {
format!("{} {:?}", t, u)
}
// Much cleaner with where clause
fn with_where<T, U>(t: T, u: U) -> String
where
T: Clone + Debug + Display,
U: Clone + Debug,
{
format!("{} {:?}", t, u)
}
fn main() {
println!("{}", with_where(42, vec![1, 2, 3]));
}PhantomData
Sometimes a type logically "contains" or "is associated with" another type, but stores no
actual value of that type. PhantomData<T> is a zero-sized marker that tells the compiler
(and future readers) about this phantom relationship:
use std::marker::PhantomData;
// A typed ID — UserId and PostId are distinct types even though
// both wrap a plain u64 underneath.
struct Id<T> {
value: u64,
_marker: PhantomData<T>,
}
struct User;
struct Post;
type UserId = Id<User>;
type PostId = Id<Post>;
fn get_user(id: UserId) -> String {
format!("user #{}", id.value)
}
fn main() {
let uid: UserId = Id { value: 1, _marker: PhantomData };
let pid: PostId = Id { value: 1, _marker: PhantomData };
println!("{}", get_user(uid));
// get_user(pid); // compile error: expected Id<User>, found Id<Post>
}PhantomData is also used in unsafe code to communicate ownership and lifetime semantics
to the compiler without storing an actual value of that type.
Summary
Concept | Syntax | Key Point |
|---|---|---|
Generic function |
| Bound constrains what T can be |
Generic struct |
| Type parameter declared after name |
Generic enum |
| Option and Result are built-in examples |
Generic impl |
| Repeat <T> on the impl keyword |
Conditional impl |
| Methods only available for bounded types |
Monomorphization | Compile-time specialization | Zero runtime overhead |
Where clause |
| Readable form of complex bounds |
PhantomData |
| Logical type relationship, zero size |