Skip to content
rusthacks.dev
Generics

Associated Types Versus Generic Parameters in Rust Traits

Use an associated type when each implementor has exactly one answer and a generic parameter when a type can implement the trait many ways. The Iterator and From traits show both.

7 min read
Summarize in AI
Flat illustration for the post “Associated Types Versus Generic Parameters in Rust Traits”, composed around generics.

A trait can mention a type in two different ways, and the choice changes how the trait is used. It can declare an associated type, trait Stack { type Item; }, or take a generic parameter, trait Convert<T>. Both let the trait refer to a type that the implementer picks. The difference is how many answers there can be: with an associated type, each implementing type gets exactly one; with a generic parameter, one type can implement the trait many times, once for each argument. This post shows both in one program that runs, the error each choice produces when misused, and a test for deciding which to use. The code ran on Rust 1.99, and every output is the program's own.

One answer per type: the associated type

An associated type is declared inside the trait and filled in by each implementation. The Stack trait holds items of some type, and the implementer says which:

src/main.rsrust
// Associated type: each implementing type has exactly one Item.
trait Stack {
    type Item;
    fn push_item(&mut self, item: Self::Item);
    fn pop_item(&mut self) -> Option<Self::Item>;
}

struct Numbers(Vec<i64>);

impl Stack for Numbers {
    type Item = i64;
    fn push_item(&mut self, item: i64) {
        self.0.push(item);
    }
    fn pop_item(&mut self) -> Option<i64> {
        self.0.pop()
    }
}

// The caller never has to name Item: it follows from the type.
fn drain_all<S: Stack>(stack: &mut S) -> Vec<S::Item> {
    let mut out = Vec::new();
    while let Some(x) = stack.pop_item() {
        out.push(x);
    }
    out
}

// Generic parameter: one type may implement the trait several times, once per T.
trait Convert<T> {
    fn convert(&self) -> T;
}

struct Celsius(f64);

impl Convert<f64> for Celsius {
    fn convert(&self) -> f64 {
        self.0 * 9.0 / 5.0 + 32.0
    }
}

impl Convert<String> for Celsius {
    fn convert(&self) -> String {
        format!("{:.1} C", self.0)
    }
}

fn main() {
    let mut s = Numbers(vec![]);
    s.push_item(1);
    s.push_item(2);
    s.push_item(3);
    println!("{:?}", drain_all(&mut s));

    let t = Celsius(21.5);
    let f: f64 = t.convert();
    let label: String = t.convert();
    println!("{f} / {label}");
    println!("{}", <Celsius as Convert<f64>>::convert(&t));
}
Text
[3, 2, 1]
70.7 / 21.5 C
70.7

Numbers implements Stack with type Item = i64. Everywhere the trait says Self::Item, for Numbers that means i64. Look at drain_all<S: Stack>(stack: &mut S) -> Vec<S::Item>: the caller names only the stack type, and the item type follows from it. There is nothing more to say, because a Numbers stack has only one possible item type. This is the property that makes associated types the right choice for relationships that are determined by the implementing type. The standard library's Iterator is the model: a given iterator yields one kind of item, type Item, so fn next(&mut self) -> Option<Self::Item> needs no extra parameter. The same goes for Deref::Target, Add::Output and Future::Output.

Because there is exactly one answer, a second implementation for the same type is rejected as a conflict:

Text
error[E0119]: conflicting implementations of trait `Stack` for type `Numbers`
  --> t170y.rs:15:1
   |
 8 | impl Stack for Numbers {
   | ---------------------- first implementation here
...
15 | impl Stack for Numbers {
   | ^^^^^^^^^^^^^^^^^^^^^^ conflicting implementation for `Numbers`

You cannot make Numbers a stack of i64 and also a stack of String. That is the intent: it is what lets callers write S::Item without ambiguity.

Many answers per type: the generic parameter

A generic parameter on the trait lets one type implement it repeatedly. Convert<T> converts a Celsius into some T, and Celsius implements it for both f64 and String:

src/main.rsrust
impl Convert<f64> for Celsius {
    fn convert(&self) -> f64 {
        self.0 * 9.0 / 5.0 + 32.0
    }
}

impl Convert<String> for Celsius {
    fn convert(&self) -> String {
        format!("{:.1} C", self.0)
    }
}

These are two different traits, Convert<f64> and Convert<String>, and both are legal for the same struct. The program picks between them by annotating the destination: let f: f64 = t.convert(); gives 70.7, and let label: String = t.convert(); gives 21.5 C. The standard library uses this form where multiplicity is the point: From<T> lets one type be built from many sources, and Add<Rhs> lets Duration add to both Duration and Instant.

The cost of the flexibility is ambiguity. When nothing at the call site says which T, the compiler cannot choose, and the message lists the candidates:

Text
error[E0283]: type annotations needed
  --> t170x.rs:21:9
   |
21 |     let x = t.convert();
   |         ^     ------- type must be known at this point
   |
note: multiple `impl`s satisfying `Celsius: Convert<_>` found
  --> t170x.rs:7:1
   |
 7 | impl Convert<f64> for Celsius {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
13 | impl Convert<String> for Celsius {
   | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
help: consider giving `x` an explicit type

The note "multiple impls satisfying Celsius: Convert<_> found" is the exact symptom of a generic trait with several implementations. Fix it by annotating the variable, as above, or by using the fully qualified syntax the program ends with: <Celsius as Convert<f64>>::convert(&t). An associated-type trait never produces this error, because there is only one candidate.

The standard example, written out

Iterator is worth implementing once to see the associated type at work. A countdown iterator names its item type once, and every adapter in the standard library then knows what flows through it:

src/main.rsrust
struct Countdown(u32);

impl Iterator for Countdown {
    type Item = u32; // the one and only item type for Countdown

    fn next(&mut self) -> Option<u32> {
        if self.0 == 0 {
            None
        } else {
            self.0 -= 1;
            Some(self.0 + 1)
        }
    }
}
Text
[4, 3, 2, 1] [6, 4, 2]

The collect call produced [4, 3, 2, 1] and the map adapter, which sees Self::Item as u32, doubled each element to give [6, 4, 2]. Nobody wrote Iterator<u32> anywhere. If Iterator were generic over its item, every adapter signature and every bound would need the extra parameter, and a type could be an iterator of two things at once, which would make for x in countdown ambiguous. The associated type is what removes that.

Writing bounds for each form

The two forms also differ in how you constrain them. With an associated type you bound the type through the trait, S: Stack<Item = i64>, which means "any stack whose items are i64", or you use S::Item in the signature. With a generic parameter, the parameter appears in the bound: C: Convert<f64>. A function that is generic over a generic trait often needs the parameter too, and the signatures grow:

src/main.rsrust
// Associated type: the bound names the relationship.
fn sum_stack<S: Stack<Item = i64>>(s: &mut S) -> i64 { /* ... */ 0 }

// Generic parameter: every function that uses the trait mentions the parameter.
fn show<C: Convert<String>>(c: &C) -> String { c.convert() }

Associated types keep signatures shorter, because the type is derived and not repeated. That is a reason to prefer them whenever the relationship really is one-to-one.

Deciding which to use

Ask one question: for a given implementing type, how many values of the extra type make sense?

AnswerUseExamples
Exactly oneAssociated typeIterator::Item, Deref::Target, Future::Output, Add::Output
Several, chosen by the callerGeneric parameterFrom<T>, Into<T>, AsRef<T>, PartialEq<Rhs>
One for output, several for inputBothAdd<Rhs> has a generic Rhs and an associated Output

The last row shows they combine well. Add is generic over the right-hand side, because a type can be added to several things, and has an associated Output, because once you know both operands the result type is fixed. When you design your own trait, apply the same split: the types the caller chooses become parameters, and the types determined by those choices become associated types.

Common mistakes

The commonest is using a generic parameter where there is only ever one sensible type. A trait Container<T> implemented by Vec<T>, HashSet<T> and so on forces every use to spell Container<T> and invites annotation errors. An associated type Item would have made fn first<C: Container>(c: &C) -> Option<&C::Item> unambiguous. The opposite mistake is using an associated type where callers need to pick, which blocks a second implementation and surfaces later as the E0119 above. A third is forgetting that associated types can be bounded in the trait definition: type Item: Display; requires every implementation's Item to be printable, so generic code can print S::Item without an extra bound.

Associated types also underlie several other features. Iterator adapters mention I::Item constantly, where clauses can bound them (I::Item: AsRef<str>), and unconstrained type parameters in impl blocks produce E0207 when the link through an associated type is missing. For the overlapping-implementation error that a generic trait avoids and an associated type triggers, see E0119, and for how both forms compile, generics and monomorphization.

Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.