How to Write Generic Functions in Rust

Write generic functions in Rust by using type parameters like <T> in the function signature to handle multiple data types.

How to Write Generic Functions in Rust

You wrote a function to find the maximum value in a list of integers. It works perfectly. Then you need the same logic for floating-point numbers. You copy the function, rename it, and change i32 to f64. A week later, you need it for a custom Score struct. You're staring at three nearly identical functions that differ only in their type signatures. The code smells. You're repeating logic, and every bug fix requires editing three places.

Rust solves this with generics. Generics let you write a function once using a placeholder for the type, then use that function with any type that meets the requirements. The compiler handles the rest. You get the flexibility of dynamic typing with the performance and safety of static typing.

The blueprint approach

Think of a generic function like a recipe that says "use a fruit" instead of "use an apple". The recipe describes the steps: peel, slice, mix. It doesn't care if you bring apples, bananas, or blueberries, as long as they are fruits. In Rust, the "fruit" is a type parameter. You define a placeholder name, usually T, and use it wherever a concrete type would go.

When you call the function, you provide the actual type. The compiler checks that the type supports all the operations the function performs. If you try to compare the fruit, the compiler ensures the type implements comparison. If the type doesn't support the operation, the compiler rejects the call. This keeps your code safe without forcing you to write separate functions for every type.

Generics are not just placeholders. They come with constraints called trait bounds. A trait bound is a promise that the type implements a specific trait. If your function uses the > operator, the type must implement PartialOrd. If your function calls .len(), the type must implement a trait that provides that method. The bounds define the contract between the function and the types it accepts.

Minimal example

Here is a generic function that finds the largest element in a slice. It works for any type that can be compared.

/// Finds the largest element in a slice by value.
fn largest<T: PartialOrd>(list: &[T]) -> &T {
    // Start by assuming the first element is the largest.
    // This requires the slice to be non-empty.
    let mut largest = &list[0];

    // Iterate through every item in the slice.
    for item in list {
        // If the current item is greater, update our reference.
        // The > operator requires PartialOrd.
        if item > largest {
            largest = item;
        }
    }

    // Return a reference to the largest item found.
    largest
}

The syntax <T: PartialOrd> introduces a type parameter T with a bound. T is the placeholder. PartialOrd is the trait that enables comparison operators like > and <. The function signature says: "This function works for any type T, provided T implements PartialOrd."

Inside the function, T behaves like a concrete type. You can use it in parameter types, return types, and local variables. The compiler treats T as a specific type once you call the function. If you call largest(&[1, 2, 3]), the compiler substitutes i32 for T. If you call largest(&[1.5, 2.5]), it substitutes f64.

What happens at compile time

Rust implements generics through monomorphization. This is the key to why generics are fast. When you write a generic function, you are writing a blueprint. The compiler uses that blueprint to generate concrete functions for every type you actually use.

If you call largest with i32 and f64, the compiler generates two separate functions in the final binary. One is specialized for i32, the other for f64. The generic code itself does not exist at runtime. There is no dispatch overhead, no virtual tables, no boxing. The generated code is identical to what you would write if you manually duplicated the function for each type.

This has two implications. First, generics are zero-cost. You pay nothing at runtime for the abstraction. Second, the binary size can grow if you use the generic function with many types. Each instantiation adds code to the binary. In practice, this is rarely a problem because the compiler optimizes aggressively, and the duplication is usually worth the maintainability gain.

Monomorphization also means that type errors are caught early. If you try to call largest on a type that doesn't implement PartialOrd, the compiler fails during monomorphization. It cannot generate the concrete function because the required trait is missing. You get a clear error pointing to the missing implementation.

Generics are compile-time polymorphism. The compiler does the work so your code doesn't pay the price.

Realistic example with multiple bounds

Real-world functions often need multiple capabilities. You might need a type that can be copied, added together, and has a default value. Here is a function that sums a slice of numbers. It uses a where clause to keep the signature readable.

/// Calculates the sum of a slice, requiring the type to be copyable and additive.
fn sum<T>(list: &[T]) -> T
where
    T: Copy + std::ops::Add<Output = T>,
    T: Default,
{
    // Start with the default value, which is zero for numeric types.
    let mut total = T::default();

    // Add each element to the running total.
    for item in list {
        // Dereference the item and add it to total.
        // Copy allows us to use the value without moving it.
        total = total + *item;
    }

    total
}

The where clause moves the trait bounds below the function signature. This is a convention for readability. When bounds get long or complex, the signature can become hard to parse. The where clause separates the name and parameters from the constraints. The compiler treats where clauses exactly the same as inline bounds.

This function requires three traits. Default provides a starting value. Copy allows the function to use values without moving them, which is essential for numeric types. std::ops::Add enables the + operator. The Output = T associated type ensures that adding two T values produces another T. Without that, you could add two integers and get a float, which would break the return type.

You can call this function with i32, f64, or any type that implements these traits. The compiler checks all bounds and generates the specialized code. If you try to call sum on a list of strings, the compiler rejects it because str does not implement Add.

If the signature wraps three lines, move the bounds to a where clause.

Pitfalls and compiler errors

Generic code introduces specific failure modes. The most common error is E0277 (trait bound not satisfied). This happens when you call a generic function with a type that doesn't implement a required trait.

struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let points = vec![Point { x: 1, y: 2 }, Point { x: 3, y: 4 }];
    // This fails with E0277.
    let _ = largest(&points);
}

The compiler rejects this because Point does not implement PartialOrd. The error message tells you exactly which trait is missing and points to the call site. The fix is to implement the trait for your type or choose a different function. You can implement PartialOrd for Point by comparing coordinates, or you can write a custom comparison function that takes a closure.

Another pitfall is over-constraining. Adding bounds you don't need restricts the function unnecessarily. If your function only reads values, you don't need Clone or Copy. If you add those bounds, callers with types that don't implement them will get errors. Only require the traits your function actually uses. The compiler can sometimes help here. If you add a bound but never use the trait, the compiler may warn you about unused bounds.

Type inference is powerful but not magic. The compiler infers T from the arguments you pass. If you call largest(&[1, 2]), it infers i32. If you call largest(&[]), the compiler has no information. An empty slice could be &[i32], &[String], or anything. The compiler cannot guess. You must provide the type explicitly using the turbofish syntax.

// Force the type to i32.
let _ = largest::<i32>(&[]);

The turbofish ::<T> specifies the type parameter directly. This is useful for empty collections, ambiguous calls, or when you want to ensure a specific type is used. It's a small syntax detail that saves you from cryptic inference errors.

Trust the bounds. They are the contract between your function and the types it accepts.

Conventions and small details

Rust has strong conventions around generics that make code easier to read. The type parameter name T is standard for a single generic type. It stands for "Type". When you have multiple types, use U for the second one. For key-value pairs, use K and V. These names are ubiquitous in the standard library and community code. Using them signals to readers that you're following the norm.

Trait bounds should be minimal. If a function works with AsRef<str>, don't require &str. The broader bound allows callers to pass String or &str without extra conversions. This makes your function more flexible. The standard library uses this pattern extensively. Functions that accept strings often take AsRef<str> or Into<String> to handle multiple input types.

The where clause is preferred when bounds involve associated types or complex expressions. If you need to constrain an associated type like T::Item: Debug, the where clause keeps the signature clean. It's also useful when you have many bounds. If the inline bounds make the function signature hard to scan, move them to where.

Generic functions can have default type parameters, but this is rare in functions. It's more common in structs and enums. For functions, explicit type parameters are the norm. If you find yourself needing defaults, you might be modeling the problem wrong. Functions should be simple and focused.

Convention is the glue that holds generic code together. Stick to T, K, V, and minimal bounds, and your code will feel idiomatic.

When to use generics

Generics are a tool, not a default. Choose the right abstraction for the job.

Use generics when you need the same logic for multiple types and want zero runtime overhead. The compiler generates specialized code for each type you use, giving you performance equal to hand-written duplication with far less maintenance.

Use concrete types when the function only works with one specific type. Adding a generic parameter adds complexity without benefit if the logic relies on type-specific behavior like string parsing, integer bit manipulation, or FFI calls. Concrete types make the intent clear and prevent misuse.

Use trait objects (dyn Trait) when you need to store heterogeneous types in a collection or dispatch behavior at runtime. Generics require the types to be known at compile time; trait objects allow a list to hold different types that share a common interface. Trait objects use dynamic dispatch, which has a small runtime cost but enables flexibility that generics cannot provide.

Use macros when you need to generate code that varies in syntax or structure, not just types. Generics handle type variation; macros handle code variation. If you need to generate different function signatures, match arms, or derive implementations, macros are the right tool. Generics cannot change the shape of the code, only the types within it.

Generics shine when the algorithm is stable and the types vary. Reach for them when you see duplication driven by type differences.

Where to go next