When copy-paste becomes a maintenance trap
You wrote a Point struct with two f64 fields for x and y. A week later you need integer points for grid coordinates. You copy the struct, rename it IntPoint, and move on. A week after that you need points with timestamps. Then points with user IDs. Your codebase grows a thicket of nearly identical structs that differ by exactly one line. You are maintaining three copies of the same logic. Rust has a better answer. You define the shape once with a placeholder type, and the compiler generates the concrete types you need. You write one definition; you get many specialized types for free.
The cookie cutter model
Think of a generic struct like a cookie cutter. You make one cutter with a specific shape. When you press it into dough, you get a cookie. The cutter isn't the cookie. The cutter is the template. In Rust, the generic definition is the cutter. When you use Point<i32>, the compiler presses that cutter into the codebase and stamps out a real Point struct where every T is replaced by i32. This stamping process is called monomorphization. The result is a concrete type that exists at compile time. There is no runtime overhead. Point<i32> and Point<f64> are completely separate types in the final binary, just as if you had written them by hand.
Minimal example: one shape, many types
Here is the smallest case: a struct with a single type parameter, used with two different concrete types.
struct Point<T> {
x: T,
y: T,
}
fn main() {
// T becomes i32 here. The compiler creates a Point<i32> type.
let grid: Point<i32> = Point { x: 3, y: 4 };
// T becomes f64 here. The compiler creates a Point<f64> type.
let map: Point<f64> = Point { x: 51.5, y: -0.1 };
// Type inference works too. The compiler sees u8 literals and picks Point<u8>.
let small = Point { x: 1u8, y: 2u8 };
println!("{}, {}", grid.x, grid.y);
}
Convention matters here. The community uses single capital letters for type parameters. T is the default for a generic value. K and V stand for Key and Value in maps. E is reserved for Error types. The compiler doesn't care about the letter, but following convention makes your code instantly readable to other Rustaceans.
What the compiler actually does
When you compile Point<i32>, the compiler generates a struct definition with two i32 fields. When you compile Point<f64>, it generates a struct with two f64 fields. These are distinct types. You cannot assign a Point<i32> to a variable expecting Point<f64>. The compiler treats them as unrelated, like String and Vec<u8>. This strictness is a feature. It prevents accidental mixing of incompatible data types that share the same generic definition. The type system catches errors at compile time, not runtime.
Multiple type parameters
You aren't limited to one placeholder. Add as many as the data structure requires. HashMap<K, V> uses two. Result<T, E> uses two. The parameters can be different types, or they can be the same. The compiler handles all combinations independently.
Here is a pair where the two halves can differ.
struct Pair<A, B> {
left: A,
right: B,
}
fn main() {
// A is i32, B is String.
let mixed: Pair<i32, String> = Pair {
left: 42,
right: String::from("hello"),
};
// A and B can be the same type.
let same: Pair<u8, u8> = Pair { left: 1, right: 2 };
println!("{}, {}", mixed.left, mixed.right);
}
Constraining what T can be
A bare T is a black box. The compiler knows nothing about it. You can't add T + T. You can't print T. You can't clone T. To do anything useful, you must constrain T with trait bounds. Trait bounds tell the compiler: "Only allow types that implement these traits."
Here is a point that supports addition and printing.
use std::fmt::Display;
use std::ops::Add;
// T must support addition, copying, and printing.
struct AddablePoint<T: Add<Output = T> + Copy + Display> {
x: T,
y: T,
}
impl<T: Add<Output = T> + Copy + Display> AddablePoint<T> {
// Translate returns a new point. Copy allows reusing self.x without moving.
fn translate(&self, dx: T, dy: T) -> AddablePoint<T> {
AddablePoint {
x: self.x + dx,
y: self.y + dy,
}
}
}
fn main() {
let p = AddablePoint { x: 3, y: 4 };
let moved = p.translate(1, 2);
println!("({}, {})", moved.x, moved.y);
}
When bounds get long, the angle brackets become hard to read. The community prefers the where clause for complex constraints. It hoists the bounds to the bottom of the signature. The meaning is identical, but the structure is cleaner.
Here is the same impl block using a where clause.
// where clause separates the type from its constraints.
impl<T> AddablePoint<T>
where
T: Add<Output = T> + Copy + Display,
{
fn translate(&self, dx: T, dy: T) -> AddablePoint<T> {
AddablePoint { x: self.x + dx, y: self.y + dy }
}
}
Trust the bounds. If you can't prove the trait, the code won't compile.
A realistic example: a generic cache
Generics shine when you wrap standard library types with custom behavior. Here is a cache that tracks hits and misses.
use std::collections::HashMap;
use std::hash::Hash;
// K must be hashable for the map. V must be cloneable to return copies.
struct Cache<K: Eq + Hash, V: Clone> {
store: HashMap<K, V>,
hits: u64,
misses: u64,
}
impl<K: Eq + Hash, V: Clone> Cache<K, V> {
fn new() -> Self {
Cache { store: HashMap::new(), hits: 0, misses: 0 }
}
// Returns Some(cloned_value) on hit, None on miss.
fn get(&mut self, k: &K) -> Option<V> {
match self.store.get(k) {
Some(v) => {
self.hits += 1;
Some(v.clone())
}
None => {
self.misses += 1;
None
}
}
}
}
The cache works for any key-value pair that meets the bounds. Cache<String, i32> is a different type than Cache<u32, String>. The compiler generates both. You get type safety for free.
Here is how you use the cache in main.
fn main() {
// Cache<String, i32> is a concrete type generated by the compiler.
let mut counts: Cache<String, i32> = Cache::new();
counts.store.insert("apples".into(), 7);
counts.store.insert("oranges".into(), 12);
println!("{:?}", counts.get(&"apples".into()));
println!("{:?}", counts.get(&"missing".into()));
}
Specializing for concrete types
You can write impl blocks for specific types alongside the generic one. impl Point<f64> adds methods that only exist when T is f64. This is useful for type-specific optimizations or operations. Don't overuse it. If you need many specializations, consider traits instead.
Here is a generic impl paired with a specialized one.
impl<T> Point<T> {
fn new(x: T, y: T) -> Self { Point { x, y } }
}
// This method only exists on Point<f64>.
impl Point<f64> {
fn distance(&self) -> f64 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
Specialization is a tool for edge cases. Use it when a concrete type needs unique behavior that the generic version can't express.
Scoping bounds to methods
You can place trait bounds on individual methods instead of the whole struct. This keeps the struct flexible. Point<T> can hold any type, but print() only works when T implements Display. This is often better than constraining the struct, because it allows the struct to be used in contexts where the bound isn't needed.
Here is a method with its own bound.
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
// This method requires T to be Display, but the struct itself does not.
// Point<i32> exists, but you can only call print() if i32 implements Display.
fn print(&self)
where
T: std::fmt::Display,
{
println!("({}, {})", self.x, self.y);
}
}
Scope your bounds. Put them on the method when possible. Keep the struct generic.
Common pitfalls
If you forget the type parameter on the impl block, the compiler rejects you with E0107 (missing generics for struct). The fix is to write impl<T> Point<T>. The brackets after impl declare the parameter; the brackets after the struct name use it.
If you try to add T without the bound, the compiler rejects you with E0277 (trait bound not satisfied). The compiler won't guess. Add the bound where the operation happens.
If you define Foo<T> but never use T in the fields, the compiler warns that the parameter is never used. This usually means you forgot to use the type. If you intentionally need a phantom type for tagging, add std::marker::PhantomData<T> to the struct. This tells the compiler the type matters for variance or drop checking, even if it doesn't occupy memory.
The compiler is your type checker. If it complains about a missing bound, you're trying to do something the type doesn't support. Add the bound.
When to reach for what
Use a generic struct when the data shape is identical across types and you want zero-cost specialization. Stack<T>, Cache<K, V>, Result<T, E> are classic examples. The compiler generates a separate copy for each type you use.
Use an enum when you have a fixed set of variants with different fields. enum Shape { Circle(f64), Square(f64) } is not a job for generics. Enums model distinct cases; generics model reusable shapes.
Use trait objects (Box<dyn Trait>) when you need a collection of different types that share a behavior. Generics give you compile-time dispatch and monomorphization. Trait objects give you runtime dispatch and a single type. Pick trait objects only when you must store heterogeneous types together.
Use PhantomData<T> when you need a type parameter for variance or drop checking but the type doesn't appear in the fields. This is a marker, not a storage field.