Generic Functions
Understand how to write generic functions in Rust that operate on multiple types, reduce code duplication, and leverage trait bounds to enforce capabilities.
When a piece of logic works the same way across different types, writing a separate copy for each type is tedious and error-prone. Generic functions solve this by letting you write a single function definition that can accept arguments of many concrete types, while Rust's type system still checks every call site individually.
What a Generic Function Is
A generic function is a function parameterized by one or more types. Instead of fixing the type of a parameter or return value, you use a placeholder—conventionally T—that the compiler replaces with a specific type each time the function is called. The result is a single piece of code that handles any type satisfying the function's requirements.
Consider two nearly identical functions that find the largest value in a slice, one for i32 and one for char:
fn largest_i32(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn largest_char(list: &[char]) -> &char {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
The bodies are identical. The only difference is the type mentioned in the signature. A generic function captures that common logic once.
Defining a Generic Function
Place a type parameter declaration inside angle brackets right after the function name, then use that name in the parameter list and return type.
fn largest<T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
Read the signature as: “For any type T, largest takes a slice of T and returns a reference to a T.” The <T> tells the compiler that T is a generic type parameter, not a concrete type that already exists. Within the function body, T acts as a placeholder that will be filled in by the concrete type at each call site.
By convention, generic parameter names in Rust are short and use CamelCase. T is the default when there is a single unknown type, while for multiple parameters you'll often see K and V (key and value) or U, V, etc. You can use any name you like—fn describe<ItemType>(item: &ItemType) is perfectly valid—but sticking to single letters keeps signatures compact.
Won't Compile:
The code above does not compile. The function body uses the > operator on values of type &T, but the compiler has no idea whether T supports ordering. This is a deliberate starting point that we fix in the next section.
Why Bounds Are Necessary
Rust checks generic code before it knows the concrete type. If the function body performs an operation on T, the compiler must be convinced that every possible T can handle that operation. Otherwise, the code is rejected.
When you try to compile largest<T>, the compiler responds:
error[E0369]: binary operation `>` cannot be applied to type `&T`
= note: an implementation of `std::cmp::PartialOrd` might be missing for `T`
The error pinpoints the mismatch: > is only meaningful for types that implement the PartialOrd trait. To tell the compiler “T can be any type that supports ordering,” you add 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
}
Now the function compiles. The bound T: PartialOrd restricts T to types that implement the PartialOrd trait, which includes i32, char, f64, and most standard library types that can be compared with >.
Compilation Successful:
With the bound in place, the function compiles and can be called with any slice of a type that implements PartialOrd. The same single definition works for numbers, characters, and custom types that derive the trait.
From the perspective of someone new to the language, a trait bound is simply a promise: “I, the function, will only be called with types that can do the things I need.” Without that promise, the compiler refuses to guess.
Multiple Type Parameters
A function can be generic over more than one type. Separate each parameter with a comma inside the angle brackets.
fn swap_pair<A, B>(a: A, b: B) -> (B, A) {
(b, a)
}
fn main() {
let result = swap_pair("hello", 42);
println!("{:?}", result); // (42, "hello")
}
Here A and B are independent; no relationship between them is required. The function works with any combination of types. The compiler infers the concrete types from the arguments: when called with ("hello", 42), A becomes &str and B becomes i32.
Where Clauses for Readability
When bounds become numerous, placing them all between <...> makes the signature hard to scan. Rust provides a where clause that moves the bounds after the parameter list.
use std::fmt::Display;
fn show_both<A, B>(a: &A, b: &B) -> String
where
A: Display + PartialOrd,
B: Display + Clone,
{
if a > b {
format!("{} comes after {}", a, b)
} else {
format!("{} comes after {}", b.clone(), a)
}
}
The where clause separates the “what types” from “what those types can do.” For a function with three or more parameters, this is often the difference between a readable signature and a wall of angle brackets. The semantics are identical to inline bounds; you can choose whichever reads better for the situation at hand.
How Beginners Should Think About Generic Parameters
A helpful mental model: think of T as a function parameter, but for types instead of values. Just as x: i32 means “the caller will provide a number,” <T> means “the caller will provide a concrete type.” The compiler then stamps out a specialized version of the function for each provided type—a process called monomorphization that incurs no runtime overhead.
Inside the function body, T is opaque. You cannot assume it has methods, fields, or even a fixed size unless you declare those capabilities through trait bounds. This is a deliberate constraint that prevents surprises later.
Common Mistakes
Forgetting to Declare the Type Parameter:
If you write fn largest(list: &[T]) without the leading \u003cT\u003e, the compiler complains that T is not recognized as a type. The declaration must appear between the function name and the parameter list: fn largest\u003cT\u003e(list: &[T]).
Assuming T Supports Arithmetic:
Operations like +, -, * are not available on an unconstrained T. You need the corresponding trait bound—std::ops::Add for +, for instance—before you can use them. Writing a + b on two unconstrained T values is a compilation error.
Moving a Generic Value Without Clone or Copy:
If a function takes ownership of a T and later tries to use it twice, the move will fail unless T: Copy or the logic avoids the second use. Similarly, creating a copy with let x = some_t; requires T: Clone or T: Copy to be explicit.
A subtler mistake is adding bounds that are wider than necessary. If your function only needs to print a value, bound T by Display, not Debug + Clone + PartialOrd. Over-constraining limits the types that can call the function and makes the signature misleading.
Practical Usage
Generic functions appear throughout Rust's standard library and in everyday application code. Vec::new() is generic: fn new() -> Vec<T>. Option::map is generic: fn map<U, F>(self, f: F) -> Option<U>. Any time you find yourself copying a function and changing only the types, a generic parameter is the right tool.
A common utility that benefits from generics is converting a collection into a human-readable string:
use std::fmt::Display;
fn join_to_string<T: Display>(items: &[T], separator: &str) -> String {
items
.iter()
.map(|item| item.to_string())
.collect::<Vec<String>>()
.join(separator)
}
fn main() {
let numbers = [1, 2, 3, 4];
let words = ["apple", "banana", "cherry"];
println!("{}", join_to_string(&numbers, ", ")); // 1, 2, 3, 4
println!("{}", join_to_string(&words, " | ")); // apple | banana | cherry
}
The function relies on a single trait bound, Display, which guarantees that every element can be converted to a String. The logic works identically for integers, string slices, and any custom type that implements fmt::Display.
Summary
Generic functions give you a way to express an algorithm once and apply it to many types. By moving the type variations into a parameter, you eliminate duplicate code without losing compile-time safety. The compiler's monomorphization means there is no performance penalty—each concrete use gets its own specialized machine code.
If you understand that T is a placeholder and that bounds declare the placeholder's capabilities, you are ready for everything the trait system has to offer.