Generics and Traits in Rust: A Friendly Guide
I walk through Rust generics and traits — generic functions, structs, enums, trait definitions, bounds, where clauses, and the standard library traits you will use in every Rust program.

TypeScript developers know what an interface looks like. You write method signatures, the type checker enforces them, and at runtime the interface evaporates completely — it’s a useful fiction the compiler maintains on your behalf.
Rust traits look almost identical at first glance. Define some method signatures, implement them on a type. But that surface resemblance is a starting point, not a definition. Traits do something TypeScript interfaces cannot: they carry default implementations, and those defaults compile to real, callable code. Every standard library type in Rust — strings, numbers, iterators, error types — exposes its behavior through traits. Once generics and traits click, you stop seeing them as advanced features and start seeing them as the basic grammar of the language.
This post walks through generic functions, generic structs and enums, trait definitions, trait implementations, trait bounds, and the where clause — all grounded in The Rust Programming Language’s chapter 10. If TypeScript generics are still fresh territory for you, my earlier post on TypeScript generics covers the same concept there. I’ll note the comparisons as we go.
Series: Part 8 of 12. Start with Part 1: Learning Rust as a TypeScript Developer
On this page
- Generic functions: one definition, many types
- Generic structs and enums: how Option and Result actually work
- Traits: shared behavior without a class hierarchy
- Trait bounds and where clauses
- Standard library traits you will use in every program
- The orphan rule: when you cannot implement a trait
- Trait bounds make your intent explicit at the compiler level
Generic functions: one definition, many types
Say you want a function that finds the largest value in a slice. You could write one for i32 and one for char — identical logic, different types, and now two copies to maintain. That duplication is exactly what generics prevent.
In TypeScript you’d write function largest<T>(list: T[]): T. Rust uses the same angle-bracket syntax, but there’s a catch:
fn largest<T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest { // ← compile error
largest = item;
}
}
largest
}
The compiler rejects this. The error explains why: > is not defined for every possible type T. If T were a socket handle or a file, “greater than” has no meaning. Rust doesn’t assume — it requires you to declare what you need from T.
The fix is a trait bound:
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
T: PartialOrd tells the compiler: this function works for any type that supports ordering. i32 implements PartialOrd. char implements it too. A custom struct doesn’t — unless you implement PartialOrd for it.
In TypeScript you’d write <T extends Comparable> and define the interface yourself. Same concept, different vocabulary.
One thing that surprised me: generic code in Rust runs exactly as fast as hand-written specialised code. At compile time, the compiler looks at every place you call a generic function with a concrete type, then generates a separate version for each. largest called with i32 and largest called with char become two separate functions in the compiled binary. No virtual dispatch, no boxing, no runtime type checks. The Rust Book calls this monomorphization. TypeScript generics disappear at runtime through erasure; Rust generics disappear through specialisation. The abstraction is free.
Generic structs and enums: how Option and Result actually work
You’ve been using Option<T> and Result<T, E> since your first Rust program. They aren’t special built-in types. They’re generic enums in the standard library:
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
That’s it. Option<String> and Option<u32> are the same enum definition with different concrete types substituted in. When you pattern-match on None, you’re handling the case where T was never provided.
Structs work the same way. A point that works for both integers and floats:
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
Notice impl<T> Point<T> — the T after impl declares that we’re implementing methods on the generic type. TypeScript developers find this redundant-looking at first. In Rust it matters: impl Point<f64> (no angle bracket after impl) means “only for the concrete float version,” while impl<T> Point<T> means “for any T.”
You can implement methods on a specific concrete type even though the struct is generic:
impl Point<f64> {
fn distance_from_origin(&self) -> f64 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
Point<i32> doesn’t get distance_from_origin. Point<f64> does. The compiler tracks this statically — the specialisation is zero-cost.
Try this
- Add
#[derive(Debug)]above yourPoint<T>struct definition.- Create
let p = Point { x: 3.0_f64, y: 4.0 };and print it withprintln!("{:?}", p).- Remove the derive and try calling
p.distance_from_origin()instead.Expected result: With
derive(Debug), the struct prints its fields.distance_from_origincompiles only whenTisf64. TryPoint { x: 3_i32, y: 4 }and watch the compiler reject the distance call.
Traits: shared behavior without a class hierarchy
A trait defines a set of method signatures that a type must provide. Here’s a simple one — a Label trait for anything that can produce a one-line description of itself:
pub trait Label {
fn label(&self) -> String;
}
Two different types, one trait:
struct Commit {
hash: String,
message: String,
}
struct Tag {
name: String,
version: String,
}
impl Label for Commit {
fn label(&self) -> String {
format!("{}: {}", &self.hash[..7], self.message)
}
}
impl Label for Tag {
fn label(&self) -> String {
format!("{} ({})", self.name, self.version)
}
}
This looks exactly like implementing a TypeScript interface — and for required methods, the behavior is identical. The difference appears when you add a default implementation:
pub trait Label {
fn label(&self) -> String;
fn short_label(&self) -> String {
let l = self.label();
if l.len() > 20 {
format!("{}…", &l[..20])
} else {
l
}
}
}
Any type that implements label automatically gets short_label for free. TypeScript interfaces cannot do this. They describe shape — they cannot supply implementations.
This is the conceptual gap between Rust traits and TypeScript interfaces. A trait isn’t only a shape requirement; it’s a vocabulary item you can extend with useful default behaviour that any implementor inherits.
Default implementations can also call other methods in the same trait, which is how the standard library builds rich APIs from narrow contracts. Implement one method and get dozens of behaviours for free. We’ll see that clearly in a moment with Iterator.
Trait bounds and where clauses
When a function needs a generic type to have specific behavior, you express that as a bound. Two syntax forms are equivalent — choose whichever reads better at the call site:
// Explicit trait bound
pub fn print_label<T: Label>(item: &T) {
println!("{}", item.label());
}
// impl Trait — syntactic sugar for the same thing
pub fn print_label(item: &impl Label) {
println!("{}", item.label());
}
When a type must satisfy multiple traits, combine them with +:
pub fn display_and_compare<T: std::fmt::Display + PartialOrd>(a: &T, b: &T) {
if a > b {
println!("{a} is larger");
} else {
println!("{b} is larger");
}
}
With two or more generic parameters each carrying multiple bounds, the signature gets cluttered. The where clause moves those bounds after the signature:
fn process<T, U>(item: &T, config: &U) -> String
where
T: Label + Clone,
U: std::fmt::Debug,
{
format!("{} — {:?}", item.label(), config)
}
The where clause doesn’t change what the function does. It changes where the constraint information lives, which makes the relationship between the parameter names and their requirements easier to follow. For anything longer than a single bound, where is worth it.
There’s a third form worth knowing — returning impl Trait from a function:
fn make_label() -> impl Label {
Commit {
hash: String::from("a3f9c1b"),
message: String::from("fix null dereference in parser"),
}
}
The caller gets a value that implements Label without knowing the concrete type. This is useful when the concrete type is complex or private. The limitation: impl Trait in return position only works when the function returns a single concrete type. You cannot return either a Commit or a Tag behind impl Label — the compiler needs to know the exact type at compile time.
Standard library traits you will use in every program
A handful of standard library traits come up in almost every Rust program. Learning them early saves a lot of confusion.
std::fmt::Display controls how a type formats with {} in print statements. If you want your type to print cleanly to the user, implement this:
use std::fmt;
impl fmt::Display for Commit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", &self.hash[..7], self.message)
}
}
std::fmt::Debug formats with {:?} for developer output and logging. You rarely need to implement it manually — the derive macro generates it automatically when all fields are already Debug:
#[derive(Debug)]
struct Tag {
name: String,
version: String,
}
Clone enables explicit value duplication via .clone(). As covered in the ownership post, Rust doesn’t copy heap data by default. Clone makes that duplication explicit and visible at every call site, which is exactly the point — you see where duplication happens.
Iterator is where the leverage becomes obvious. Implementing this trait requires a single method:
fn next(&mut self) -> Option<Self::Item>
In return, the standard library provides around 70 methods — .map(), .filter(), .flat_map(), .zip(), .collect(), and more — all as default implementations built on top of next. One method, seventy free behaviours. That’s what default implementations in traits make possible at scale.
The orphan rule: when you cannot implement a trait
Here’s the constraint that surprises most developers coming from other languages. You can implement a trait on a type only if at least one of them is local to your crate.
You can implement the standard library’s Display on your own Commit struct — Commit is yours. You can implement your custom Label trait on Vec<T> — Label is yours. But you cannot implement Display on Vec<T> inside your own crate, because both Display and Vec<T> belong to the standard library.
The Rust Reference calls this the coherence rule, and the specific case the orphan rule. The reason is straightforward: without it, two independent crates could each implement Display for Vec<String>, and when both crates appear in the same project, the compiler would have no way to choose between them.
If you genuinely need to add trait behavior to an external type, the standard workaround is the newtype pattern — wrap the external type in a local struct:
struct CommaSeparated(Vec<String>);
impl fmt::Display for CommaSeparated {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0.join(", "))
}
}
CommaSeparated is a local type, so you can implement any trait for it. The cost is a thin wrapper you unwrap at the call site. In practice this comes up less often than you’d expect — the standard library traits cover most common needs, and your own traits can be implemented freely on any type.
Trait bounds make your intent explicit at the compiler level
When you write T: PartialOrd + Clone, you’re not just constraining a function — you’re documenting what it requires and letting the compiler enforce it everywhere, permanently, for free. No runtime assertion, no defensive null check, no documentation that quietly drifts from the code.
That shift is worth sitting with. In TypeScript, interfaces mostly describe what a value looks like — its shape at a point in time. In Rust, traits describe what a value can do — and the compiler tracks every guarantee transitively from trait definition to every call site.
The parts covered here — generic functions, generic structs and enums, trait definitions, implementations, bounds, and the where clause — are all from chapter 10 of The Rust Programming Language. Chapter 10 has one more piece: lifetime parameters, which interact with generics and trait bounds when references are involved. That’s the topic a later part of this series will return to.