Lesson 03 / 15
Associated Types
A trait can carry a type within itself without taking a type parameter — an associated type; when the same contract is written with an associated type and with a generic parameter, the run count comes out exactly the same, the difference is in what the caller has to write in the signature, and an associated type carries a constraint that allows only one implementation per type.
Published Updated
In this lesson
- Defines the associated type syntax and distinguishes it from the same contract written with a generic parameter
- Shows that the two forms' run counts come out the same, the difference being in the information the caller has to write
- Compares and explains that an associated type is a constraint allowing one implementation per type, while a generic parameter allows no such thing
Contents
The previous lesson showed how a trait bound constrains a type parameter from the outside:
the T: Label syntax said which types the caller could pass in. A trait can sometimes do the
opposite too — carry a type within the contract itself, a type decided not by the caller
but by the implementer. The kernel that ran in the course’s first lesson already had an example
of this: the Add trait’s implementation carried the line type Output = Length; — that line
in the operator-overloading section is exactly this lesson’s subject, the associated type.
That lesson passed over it without introducing it; this lesson comes back to it.
A trait bound answered the caller’s question of “which types can I pass to this function.” An associated type answers a different question: “a type implementing this contract carries which other type alongside itself.” A trait can declare not just method signatures but also a helper type those methods work with — this helper type can differ per implementation, but is fixed for any single implementation. This means a contract can define not just “what will be done” but also “which type it will work with.”
Neither of the two bodies measured in this lesson leaves the course’s three classes — both fall into just one: every time. Neither the associated type nor the generic parameter causes a body to be deferred or skipped — both only shape the body’s signature, not whether it runs when called. This is the same observation as the previous two lessons: the class is determined not by how the body is named or where it is bound, but by what happens when it is called.
What Is an Associated Type
Writing type Name; inside a trait declaration means “every type implementing this contract
must also declare a Name type within itself.” During implementation, this slot is filled by a
type Name = ConcreteType; line. The two traits below define the same job with two different
notations:
// times.rs — slice: the record/times ledger, the associated-type and generic-parameter contracts.
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
pub type Ledger = Rc<RefCell<BTreeMap<String, u32>>>;
pub fn record(ledger: &Ledger, name: &str) {
*ledger.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
}
pub fn times(ledger: &Ledger, name: &str) -> u32 {
*ledger.borrow().get(name).unwrap_or(&0)
}
// Contract carries a type within itself: Product is decided by the implementer.
pub trait Contract {
type Product;
fn produce(&self, ledger: &Ledger) -> Self::Product;
}
// The same idea, with a generic parameter: C is a type the caller supplies from outside.
pub trait GenericContract<C> {
fn produce_generic(&self, ledger: &Ledger) -> C;
}
pub struct Container {
pub value: i32,
}
impl Contract for Container {
type Product = i32;
fn produce(&self, ledger: &Ledger) -> i32 {
record(ledger, "associated-type-body");
self.value
}
}
impl GenericContract<i32> for Container {
fn produce_generic(&self, ledger: &Ledger) -> i32 {
record(ledger, "generic-parameter-body");
self.value
}
}
The Self:: prefix in the Self::Product syntax is not arbitrary: when the trait declaration
is written, it is not yet known which type will implement this contract, so Product cannot be
written as a bare name — it has to be written as Self::Product, meaning “the implementing
type’s own Product.” Inside the impl block this ambiguity disappears — since Self now
corresponds to Container, the line type Product = i32; can write i32 directly, with no
need for the Self:: prefix. The same name (Product) plays two different roles: a
placeholder in the trait declaration, a decision inside the implementation.
In Contract, Product is part of the contract: the return type of the produce method
is written as Self::Product, not a concrete type name. When Container implements this
contract, it declares once, at the top of the impl block, that Product is i32; the rest
of the block inherits this decision. In GenericContract<C>, the same information is instead
inside the trait’s name: when impl GenericContract<i32> for Container is written, C
resolves to i32 there, but because this information is part of the trait name, it can be
repeated more than once, with different C values — the constraint side of this difference is
looked at below.
Same Count, Different Syntax
Calling both notations the same number of times makes the run count directly comparable.
// associated-type.rs — the same job in two notations: associated type and generic parameter.
mod times;
use times::{times, Ledger, Container, Contract, GenericContract};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
fn main() {
let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
let container = Container { value: 9 };
// The caller never writes Product in the associated-type form - the compiler already knows it.
let _ = container.produce(&ledger);
let _ = container.produce(&ledger);
let _ = container.produce(&ledger);
// The same number of calls, the same body, with the generic parameter too.
let _ = GenericContract::<i32>::produce_generic(&container, &ledger);
let _ = GenericContract::<i32>::produce_generic(&container, &ledger);
let _ = GenericContract::<i32>::produce_generic(&container, &ledger);
let mut bodies: BTreeMap<&str, u32> = BTreeMap::new();
bodies.insert("associated-type-body", times(&ledger, "associated-type-body"));
bodies.insert("generic-parameter-body", times(&ledger, "generic-parameter-body"));
println!("{:<24}{}", "body", "how many times");
for (name, n) in &bodies {
println!("{:<24}{}", name, n);
}
}
body how many times associated-type-body 3 generic-parameter-body 3
TG5. Both ran three times — two rows in the every time class, the exact same number.
The difference does not show up in the run ledger, it shows up in the call line itself:
container.produce(&ledger) never says what Product is, because the compiler already knows
it from Container‘s Contract implementation. GenericContract::<i32>::produce_generic(&container, &ledger), on the other hand, carries i32 explicitly — because Container could have more
than one implementation of GenericContract (shown below), stating which one is meant becomes
the caller’s job.
The Interfaces, Errors and Generics course wrote Go’s generics from the binding axis; what’s measured here is not the question that course asked, it is the two notations’ body count — and the count comes out exactly equal. This shows that an associated type and a generic parameter carry no different cost at run time; the difference between them is entirely in the source’s readability and in the information the caller has to write.
An Associated Type Is Per Type
The constraint applies not to the trait as a whole, but separately to each
implementation. Container‘s Product is fixed to i32 once, but another type can
implement the same Contract with its own Product — what’s being constrained is not “this
trait must use one Product everywhere,” it is “this type must have chosen one Product for
this trait.”
// two-types.rs — an associated type is fixed per implementation, not per trait.
mod times;
use times::{times, Ledger, Container, Contract, GenericContract};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
struct Labeled {
name: String,
}
impl Contract for Labeled {
type Product = String;
fn produce(&self, ledger: &Ledger) -> String {
times::record(ledger, "labeled-body");
self.name.clone()
}
}
fn main() {
let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
let container = Container { value: 5 };
let labeled = Labeled { name: String::from("title") };
let numeric: i32 = container.produce(&ledger);
let textual: String = labeled.produce(&ledger);
let _ = GenericContract::<i32>::produce_generic(&container, &ledger);
println!("numeric={} textual={}", numeric, textual);
println!("associated-type-body={}", times(&ledger, "associated-type-body"));
println!("labeled-body={}", times(&ledger, "labeled-body"));
}
numeric=5 textual=title associated-type-body=1 labeled-body=1
TG8. Container implements the same Contract with Product = i32, Labeled with
Product = String, and both sit side by side in the same source — this shows that the trait
itself is not bound to one type, only that type’s implementation is. The constraint still
holds: neither Container nor Labeled can implement Contract a second time with a
different Product. The example also calls GenericContract<i32> on Container once more —
to show that two separate contracts (Contract and GenericContract) can coexist on the same
type in the same source, without excluding each other.
Using It Inside Generic Code
The difference looks small while calling on a single concrete type (Container): both add
about three words, a difference of barely a few characters. The difference grows once Contract
or GenericContract is used in the bound of another generic function.
// generic-use.rs — using Contract in the bound of another generic function.
mod times;
use times::{times, record, Ledger, Container, Contract, GenericContract};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
// No need to write the Product type separately - T::Product is resolved by the compiler.
fn apply<T: Contract>(x: &T, ledger: &Ledger) -> T::Product {
record(ledger, "generic-use-body");
x.produce(ledger)
}
// The same idea with a generic parameter: C has to be ADDED to the signature SEPARATELY.
fn apply_generic<T: GenericContract<C>, C>(x: &T, ledger: &Ledger) -> C {
record(ledger, "generic-use-generic-body");
x.produce_generic(ledger)
}
fn main() {
let ledger: Ledger = Rc::new(RefCell::new(BTreeMap::new()));
let container = Container { value: 9 };
let a = apply(&container, &ledger);
let b = apply_generic(&container, &ledger);
println!("apply={} apply_generic={}", a, b);
println!("generic-use-body={}", times(&ledger, "generic-use-body"));
println!("generic-use-generic-body={}", times(&ledger, "generic-use-generic-body"));
}
apply=9 apply_generic=9 generic-use-body=1 generic-use-generic-body=1
TG7. apply‘s return type is T::Product — whatever T resolves to, its Product is
inherited automatically; there is no separate slot for Product in the signature, because none
is needed. apply_generic, after writing T: GenericContract<C> in its bound, is also forced
to add C to the function’s own type parameter list — C cannot be inferred automatically
from T, because T could in principle carry more than one implementation of
GenericContract, and the compiler could not know which one is meant. This shows that the
difference at the call site from the previous section turns into a signature difference
when writing generic code: apply‘s signature carries a single type parameter while
apply_generic‘s carries two, even though the second function’s only job is the same as the
first’s.
The Trait Bounds lesson’s combine<T: Label, U: Label> example showed how two independent type
parameters can stand side by side; apply_generic<T: GenericContract<C>, C> uses the same
notation, but here C is not an independent choice — it is a repetition of information T
already carries. The two functions fit syntactically into the same pattern — more than one
parameter inside <...> — but while in combine each parameter really answers a different
question, in apply_generic, C only asks the caller to rewrite information T already
carries. An associated type removes this repetition.
The Measurement’s Limit
An associated type adds a constraint to the contract: a type can implement Contract only
once, because Product is bound to a single value for that type. Once Product = i32 is
declared for Container, a second impl Contract for Container block — a second one that
tries to make Product be String — is rejected by the compiler; this rejection is not put in a
code block, because source that does not compile is never shown as a code block in this
repository. The constraint itself is described in prose; the accepted counterpart is already
run above by associated-type.rs — there is a single Contract implementation for Container,
and the source compiles as it stands.
GenericContract<C> does not carry this same constraint: because C is part of the trait name,
GenericContract<i32> and GenericContract<String> are two separate traits to the
compiler, and Container can implement both at once.
// constraint.rs — a generic parameter allows more than one implementation for the same type;
// an associated type does not (one type = one Product binding).
trait GenericContract<C> {
fn produce_generic(&self) -> C;
}
struct Container {
value: i32,
}
impl GenericContract<i32> for Container {
fn produce_generic(&self) -> i32 {
self.value
}
}
impl GenericContract<String> for Container {
fn produce_generic(&self) -> String {
self.value.to_string()
}
}
fn main() {
let container = Container { value: 9 };
let numeric: i32 = GenericContract::<i32>::produce_generic(&container);
let textual: String = GenericContract::<String>::produce_generic(&container);
println!("numeric={} textual={}", numeric, textual);
}
numeric=9 textual=9
TG6. Container implements both GenericContract<i32> and GenericContract<String> at
the same time — both are accepted, both compile. This shows that the constraint an associated
type offers is not a shortcoming but a deliberate design choice: if Contract had been used
instead, Container’s Product would be fixed to a single value, and the caller would never
have to ask “which Product?” Using GenericContract buys this flexibility, but its cost shows
up at the call site: container.produce_generic(&ledger) alone is not enough, since Container
carries two implementations — which C is meant (::<i32> or ::<String>) has to be written
explicitly. With an associated type, this statement is never needed, because there is only ever
one option.
This constraint should be read not as a shortcoming but as a design tool. If a contract
expects, by the nature of a type, to work with a single output type (say, “the element type it
holds” for a container, or “the result type it produces” for a converter), an associated type
turns this uniqueness into a syntax rule: the implementer cannot accidentally implement the same
type with two different output types, because the compiler does not allow it. A generic
parameter does not force this uniqueness — where a type genuinely can work with more than one
output form (as seen in this lesson, where a Container can have both a numeric and a textual
counterpart), it is the right tool. The reason both notations exist is that “one type = one
output” and “one type = more than one output” ask for different contracts.
Summary
- An associated type is a type slot a trait carries within itself with a
type Name;declaration, filled by the implementer; a generic parameter carries the same information inside the trait’s name. - Both notations fall into the every-time class and give exactly the same run count (three and three); the difference is not at run time, it is in the information the caller has to write in the signature.
- An associated type carries a constraint: a type can implement a trait with its associated type only once.
- A generic parameter does not carry this constraint: the same type can implement the same trait more than once with different parameter values — the cost is that the caller has to state explicitly which implementation is meant.
Next Step
These two lessons showed that the run count does not change whether the same body is written with a bound or with an associated type — both are resolved at compile time. The next lesson turns to the question of when the call is resolved: is the same contract used with a call resolved at compile time, or one resolved at run time, and does this pair change the run count?
To keep your progress and take notes, Log in
My notes
Log in to take notes.