Generic Data Types

Learn how to define and use generic functions, structs, and enums in Rust, and understand how monomorphization makes generics zero-cost abstractions

Every Rust program you have written so far used concrete types like i32, String, or Vec<f64>. That works until you need the same logic for multiple types. You could copy the function and change the type signature, but duplication leads to bugs and maintenance burdens. Generics solve this by letting you write a single definition that works with many types—without sacrificing the compile-time type checks Rust is known for.

Think of a generic type parameter as a placeholder: you write T where you would normally put i32 or char, and the compiler fills in the real type when the code is used. The compiler then checks that every operation on T is valid for whatever concrete type eventually substitutes for it.

This section covers how to write generic functions, structs, and enums. We will end with an explanation of monomorphization, the mechanism that makes Rust generics cost nothing at runtime.

Generic Functions

A function signature that works for only one concrete type forces you to duplicate logic when the same algorithm applies to multiple types. The standard example is finding the largest value in a slice.

Start with two functions that differ only in the type they operate on:

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. To eliminate the duplication, introduce a type parameter T in place of i32 and char:

fn largest<T>(list: &[T]) -> &T {
    let mut largest = &list[0];
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

The fn largest<T> reads as: "the function largest is generic over some type T." You can use any identifier for the type parameter, but Rust convention uses short CamelCase names—T is the most common.

This code does not compile yet:

The body uses >, but the compiler cannot guarantee that every possible T supports ordering. Attempting to compile this produces an error like binary operation \u003e cannot be applied to type \u0026T, and the compiler helpfully suggests restricting T with the PartialOrd trait.

The fix is to add a trait bound that says T must implement the PartialOrd trait, which provides the > operator. Trait bounds are covered in detail later in this chapter; for now, understand them as a way to tell the compiler "only types that can be compared are allowed here."

fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

This version compiles and works with slices of any type that implements PartialOrd—including i32 and char. The trait bound is part of the function's contract, enforced at compile time.

let numbers = vec![34, 50, 25, 100, 65];
let result = largest(&numbers);
println!("The largest number is {}", result); // prints 100
let chars = vec!['y', 'm', 'a', 'q'];
let result = largest(&chars);
println!("The largest char is {}", result); // prints 'y'

One function, multiple types:

The same generic function now replaces two concrete implementations. If you later need largest for a slice of f64 or any other PartialOrd type, it works without modification.

A function can accept multiple type parameters by listing them inside the angle brackets:

fn first_of_two<A, B>(a: A, _b: B) -> A {
    a
}

The names are placeholders; A and B will be replaced by the concrete types the caller provides. The function body can only perform operations that are valid for all possible types that satisfy the bounds (or, when no bounds are given, for any type at all). That is why first_of_two only returns a—it cannot meaningfully combine A and B without further constraints.

A mental model for generic functions

A generic function is not a single function that somehow figures out the type at runtime. The compiler generates a separate, optimized version for each concrete type the function is called with. When you call largest with a &[i32], the compiler produces a function specialized for i32 comparison. When you call it with a &[char], it produces a different function for char. You write one, but the machine runs many.

Generic Structs

Struct fields can use generic type parameters, making the struct reusable across different data shapes.

struct Point<T> {
    x: T,
    y: T,
}

This definition says: "Point is generic over some type T, and both fields x and y must be of that same type."

let integer_point = Point { x: 5, y: 10 };
let float_point = Point { x: 1.2, y: 3.4 };

Both examples work because T becomes i32 for the first instance and f64 for the second. However, because there is only one type parameter, the fields must agree:

let wont_work = Point { x: 5, y: 4.0 };

Mismatched types with a single generic parameter:

This fails to compile with a type mismatch: expected integer, found floating-point number. The compiler inferred T = i32 from the first field, then discovered that 4.0 is not an i32.

When the fields should be independent types, use multiple type parameters:

struct Point<T, U> {
    x: T,
    y: U,
}

Now x and y can hold values of completely different types.

let mixed = Point { x: 5, y: 4.0 };   // x: i32, y: f64
let text_and_char = Point { x: "Hello", y: 'c' }; // x: &str, y: char

You can use as many type parameters as needed, but structures with more than two or three generics become hard to read. If you find yourself adding many type parameters, the struct may be trying to do too much—consider breaking it into smaller pieces.

Too many type parameters hurts readability:

While the compiler imposes no practical limit, a struct like Wrapper<A, B, C, D, E> signals that the design may need restructuring.

Methods on generic structs

You implement methods on a generic struct by declaring the type parameters on the impl block as well:

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

The impl<T> tells the compiler that T is a generic type parameter, not a concrete type. Without it, Point<T> would be interpreted as a concrete instantiation, and the compiler would complain that T is undefined. The method x is available on every Point<T> regardless of what T actually is.

You can also restrict method availability to only certain instantiations. This is a powerful pattern: define a method that only exists when the generic type satisfies specific trait bounds.

use std::fmt::Display;
impl<T: Display + PartialOrd> Point<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

The cmp_display method only appears for Point<T> when T implements both Display (so it can be printed) and PartialOrd (so it can be compared). For a Point<MyStruct> where MyStruct lacks those traits, calling cmp_display is a compile error. This is how the standard library implements Vec::sort() only when the element type implements Ord.

Conditional methods reduce accidental misuse:

By tying a method to trait bounds, you prevent callers from attempting operations that are not meaningful for certain types. The compiler catches the misuse at compile time, not at runtime.

Generic Enums

Rust's standard library uses generic enums pervasively. You have already used Option<T> and Result<T, E> in earlier chapters. Now you can see how they are defined.

enum Option<T> {
    Some(T),
    None,
}
enum Result<T, E> {
    Ok(T),
    Err(E),
}

Option<T> abstracts the concept of an optional value. It works for any type TOption<i32>, Option<String>, Option<Vec<f64>>—because the definition is generic. The None variant carries no data, so it does not depend on T.

Result<T, E> uses two type parameters: T for the success value and E for the error value. When you open a file, T becomes std::fs::File and E becomes std::io::Error. That same Result enum type handles network requests, parsing, and any fallible operation, no matter what types the success and error carry.

When you define your own enums, you can follow the same pattern. If you have a data structure that holds one kind of value in some variants and a different kind in others, or where the payload types vary, make the enum generic over those types rather than hard-coding them.

enum ApiResponse<D, M> {
    Data(D),
    Message(M),
    Empty,
}

Here, D and M are placeholders for whatever payload types your application needs.

Performance of Generics (Monomorphization)

One of Rust's central promises is that abstractions should not impose runtime overhead. Generics fulfill that promise through monomorphization.

Monomorphization is the process of filling in the concrete types at compile time and generating a specialized copy of the generic code for each concrete type used. There is no generic largest function at runtime. Instead, the compiler emits largest_i32, largest_char, and any other version your code actually calls.

This works as if you had hand-written separate functions for each type, but you only had to write the logic once. The resulting machine code is identical—identical performance, identical inlining opportunities, identical register usage. No dynamic dispatch, no type tags, no runtime type checks.

Zero-cost abstraction:

The term "zero-cost abstraction" means you do not pay for what you do not use, and what you do use performs as well as if you had written the lower-level code directly. Generics in Rust are a textbook example of this principle.

There is one trade-off: binary size. Because the compiler generates a separate copy for each concrete type, a generic function called with ten different types will produce ten specialized versions in the compiled binary. In most applications this is negligible. In embedded or size-constrained environments, if you call a generic function with many distinct types, the binary grows proportionally. The runtime performance, however, remains unchanged.

Monomorphization also enables the conditional method pattern shown earlier. Since the compiler knows the exact type at every call site, it can decide at compile time whether a method like cmp_display is available. If you never call it with a Point<f64>, that specialization is never emitted, and no code for it ends up in the binary.

When we later explore trait objects (dyn Trait), you will see an alternative form of polymorphism that uses a single runtime representation via virtual tables. That approach trades a small runtime cost for the ability to store heterogeneous types in the same collection. Generics and monomorphization are the static, zero-cost path; trait objects are the dynamic, flexible path. Choosing between them is a central design decision in Rust, and understanding monomorphization is the prerequisite for making that choice correctly.


Generics let you write code that works across types without duplication and without giving up compile-time guarantees. The pattern is consistent across functions, structs, and enums: you introduce type parameters, apply trait bounds to restrict what operations are permitted, and the compiler generates specialized implementations for each concrete type you use. Monomorphization ensures that the abstraction has no runtime cost.