The Rust Engineering Handbook / Chapter 19
Traits and Associated Items
Design traits as behavioral contracts with explicit implementer obligations, associated relationships, laws, defaults, and evolution boundaries.
A method set is not yet a policy
trait PostingPolicy {
fn apply(&self, posting: Posting) -> Decision;
}
What may a caller infer after Decision::Accept? Perhaps the posting is unchanged, the method has written no external state, and a repeated call with the same input will agree. The signature establishes none of those facts. An implementation that silently changes the amount can satisfy the compiler as easily as one that compares it with a limit.
A trait’s methods define its callable surface. Its behavioral contract also includes relationships among associated items, laws callers may rely on, ownership and concurrency expectations, and the meaning of supplied defaults. The useful design question is therefore not “which methods belong on this trait?” but “what must every implementation make true?”
We will answer that question by commissioning one ledger policy from beginning to end. The finished trait must support a limit policy and an audit-only policy. Both inspect a posting and attach evidence to their verdict; neither may alter the posting. That small requirement will force each piece of the trait to earn its place.
Put the irreducible decision in the required method
A required method has no body, so every implementation must supply it. Begin with the smallest operation from which the rest of the policy can be derived: evaluate a borrowed posting. Borrowing prevents the decision itself from consuming the value, and the result retains the evidence that justified it.
use std::{error::Error, fmt::Debug};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Posting {
cents: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum Verdict<E> {
Accept(E),
Reject(E),
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct Evaluated<E> {
posting: Posting,
verdict: Verdict<E>,
}
trait PostingPolicy {
type Evidence: Debug + Eq;
type Error: Error;
const NAME: &'static str;
fn evaluate(
&self,
posting: &Posting,
) -> Result<Verdict<Self::Evidence>, Self::Error>;
fn apply(
&self,
posting: Posting,
) -> Result<Evaluated<Self::Evidence>, Self::Error> {
let verdict = self.evaluate(&posting)?;
Ok(Evaluated { posting, verdict })
}
}
evaluate is the required primitive. apply is provided behavior: it derives an owned result while preserving the original posting. Requiring both methods independently would let an implementer make them disagree. The default removes that second policy decision.
The bounds on Evidence are also contract, not decoration. The reusable law tests below need to inspect and compare evidence. If production callers did not need those capabilities, the better design would put the bounds on the tests instead and admit more evidence types.
Associated items make implementation-wide relationships explicit
Each policy chooses one evidence type and one error type for all of its calls. A limit policy can report its remaining headroom:
#[derive(Clone, Debug, Eq, PartialEq)]
struct RemainingCents(u64);
#[derive(Debug)]
struct PolicyError;
impl std::fmt::Display for PolicyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("policy configuration is invalid")
}
}
impl Error for PolicyError {}
struct LimitPolicy {
maximum: u64,
}
impl PostingPolicy for LimitPolicy {
type Evidence = RemainingCents;
type Error = PolicyError;
const NAME: &'static str = "limit";
fn evaluate(
&self,
posting: &Posting,
) -> Result<Verdict<Self::Evidence>, Self::Error> {
let remaining = self.maximum.saturating_sub(posting.cents);
if posting.cents <= self.maximum {
Ok(Verdict::Accept(RemainingCents(remaining)))
} else {
Ok(Verdict::Reject(RemainingCents(0)))
}
}
}
The associated types say that LimitPolicy has one natural evidence and error relationship. If the same policy type needed separate implementations for several independent posting representations, a trait parameter such as PostingPolicy<Input> would permit those distinct relationships. An associated Input would instead select only one input type per implementation. That choice affects inference, coherence, and how many implementations can exist.
NAME belongs to the policy type, so an associated constant is honest. maximum varies by policy value and comes from configuration, so moving it into a constant would erase a real runtime choice merely to make the trait look more declarative.
The syntax Self::Evidence means the evidence type chosen by the current implementer. Self can also appear as an argument or return type. On an otherwise dyn-compatible trait, a constructor such as fn from_config(config: Config) -> Self where Self: Sized remains available in generic code but cannot be called through a trait object. That is a boundary decision, not a reason to avoid Self; Chapter 21 develops the dynamic alternative.
Laws turn intended meaning into reviewable claims
The provided apply returns the input posting alongside policy-specific evidence. The type checker cannot prove that an override preserves that behavior, that evaluation is deterministic, or that implementations avoid external mutation. Those are laws of this particular trait.
Write each law as an observable claim before writing its test:
- evaluating the same posting twice produces the same verdict;
applypreserves the posting exactly;- the verdict returned by
applyagrees withevaluate.
One reusable suite can exercise those claims for every implementation:
fn assert_policy_laws<P>(policy: &P, posting: Posting)
where
P: PostingPolicy,
P::Error: Debug,
{
let first = policy.evaluate(&posting).unwrap();
let second = policy.evaluate(&posting).unwrap();
assert_eq!(first, second, "evaluation must be deterministic");
let evaluated = policy.apply(posting).unwrap();
assert_eq!(evaluated.posting, posting, "apply must preserve input");
assert_eq!(evaluated.verdict, first, "apply must agree with evaluate");
}
The suite should run boundary values such as zero, exactly the limit, and one above it. Property tests can range farther, but they do not replace review. In particular, a test cannot establish the absence of every external side effect. If purity is part of the public promise, keep the policy’s inputs explicit, document the restriction, and review implementations for hidden I/O or interior mutation.
An override of apply may be justified by performance or richer instrumentation, but it inherits the same laws. The shared suite checks observational equivalence with the default contract rather than prescribing one internal algorithm.
An audit-only implementation tests the abstraction
The second implementation is deliberately unlike the limit policy. It accepts every posting but records which audit rule was observed:
#[derive(Clone, Debug, Eq, PartialEq)]
struct AuditObservation(&'static str);
struct AuditOnly;
impl PostingPolicy for AuditOnly {
type Evidence = AuditObservation;
type Error = PolicyError;
const NAME: &'static str = "audit-only";
fn evaluate(
&self,
_posting: &Posting,
) -> Result<Verdict<Self::Evidence>, Self::Error> {
Ok(Verdict::Accept(AuditObservation("observed")))
}
}
Both implementations satisfy the same structural and behavioral contract without sharing an evidence representation. That is the expressive gain provided by the associated type. The exercise is also a check on the required primitive: no limit-specific method has leaked into the trait.
Add prerequisite contracts only when they are universal
Suppose audit metadata can be retrieved through an Audited trait. Writing trait PostingPolicy: Audited would make that trait a supertrait: every policy implementation would also have to implement Audited, and generic callers could rely on both contracts.
The audit-only policy makes that idea sound plausible; the pure limit policy exposes the mistake. Audit access is useful to one integration, not a semantic prerequisite of every posting decision. Put P: PostingPolicy + Audited on the function that needs both. A supertrait belongs on PostingPolicy only if a policy that cannot satisfy it is incoherent everywhere.
Marker traits demand the same restraint. Built-in auto traits such as Send and Sync have language-defined consequences. A local empty trait named Durable or Safe has only the enforcement supplied by its module boundaries and review process. An impressive name does not make a property compiler-proven.
Blanket implementations make a global commitment
It is tempting to give every policy a convenience method:
trait PolicyName {
fn policy_name(&self) -> &'static str;
}
impl<T: PostingPolicy> PolicyName for T {
fn policy_name(&self) -> &'static str {
T::NAME
}
}
That blanket implementation covers every current and future PostingPolicy. The method is convenient, but the implementation may overlap with a future impl or prevent a downstream crate from customizing PolicyName for one of its policy types. Coherence rejects ambiguity once it exists; it does not warn that today’s broad impl has consumed tomorrow’s design space.
Here the blanket adds almost nothing over P::NAME, so omit it. Chapter 20 follows this global ownership problem through the orphan and overlap rules.
Choose use boundaries after the contract is credible
A function with P: PostingPolicy asks the caller to choose a concrete policy type at compile time. This generic boundary preserves the concrete evidence and error types and permits static dispatch.
The trait as written cannot be the base of a trait object because dyn-compatible traits may not have associated constants. Even if NAME became a method, a trait object would have to fix Evidence and Error; the limit and audit-only policies still could not share one homogeneous collection while their evidence types differ. A dynamic adapter could expose fn name(&self) and erase both evidence forms into a common enum. A closed enum of policies could model the same finite set more directly.
Do not redesign the contract for trait objects before runtime heterogeneity is a requirement. Generic, dynamic, enum, and adapter boundaries answer when and by whom the implementation is selected. They do not repair an undefined behavioral promise.
Evolution starts with code you do not control
Adding a required method breaks downstream implementations. A provided method often avoids that immediate break, but can collide with inherent or extension methods or alter method resolution. Tightening a supertrait or associated-type bound excludes existing implementers. Adding a blanket implementation may overlap with downstream code.
Sealing the trait prevents outside implementations and buys more freedom to change required items. It also retracts the promise that another crate may supply a policy. For an application-internal trait, coordinated edits may be enough. For a published library trait, decide whether downstream implementation is part of the product before calling any change additive.
Failure modes
- Documenting method syntax but not behavioral laws.
- Using an associated type where one implementer needs several relationships.
- Turning deployment configuration into associated constants.
- Adding a supertrait because one helper happens to need it.
- Treating a local marker as compiler-proven safety.
- Publishing a broad blanket implementation without overlap analysis.
- Claiming a default method addition is universally nonbreaking.
- Designing for trait objects before confirming runtime heterogeneity exists.
Trait review questions
- What must every implementer establish beyond returning the right types?
- Which operations are minimal required primitives, and which defaults derive from them?
- Does each associated type represent one choice per implementation?
- Is every supertrait a universal semantic prerequisite?
- Which laws have reusable tests, counterexamples, and documented failure consequences?
- What future types does each blanket implementation capture?
- Is downstream implementation allowed, discouraged, or sealed?
- Will callers use a generic bound, trait object, enum, or concrete adapter?
API exercise: make the contract resist a new requirement
Run the law suite against LimitPolicy at zero, at the limit, and one cent above it, then against AuditOnly. Add a policy whose evaluation can fail because its ruleset is unavailable. Decide whether that failure belongs in Error or in Verdict, and defend the caller behavior implied by your choice.
Then introduce a deployment that chooses policies at runtime. Preserve each policy’s evidence if callers use it; otherwise design an explicit erased evidence enum. Compare a trait object with a closed policy enum. Finally, write a migration note for a new optional operation: name possible method collisions and downstream implementers instead of claiming that a default body makes the change universally harmless.
Durable takeaways
- Required methods should be the smallest implementer obligations from which honest defaults can be derived.
- Associated items encode relationships chosen once by each implementation.
- Laws state the behavioral meaning that signatures cannot express and reusable tests make part of that meaning inspectable.
- Supertraits, markers, defaults, and blanket implementations make semantic and compatibility commitments.
- Generic, dynamic, enum, adapter, and sealed boundaries are separate architectural choices.
Once a trait’s meaning is clear, a second question becomes unavoidable: who is allowed to attach that meaning to a type? The answer cannot be “every crate that finds the pairing convenient,” because independently developed implementations must still compose into one unambiguous program. Coherence turns that global constraint into the next design boundary.
Sources and version notes
- Rust Reference: traits, associated items, and implementations
- Rust API Guidelines: dependability and future proofing
- Standard library marker traits
- The complete policy example and reusable law suite pass Rust 1.97.0 and Rust 1.85.0. Compatibility judgments are recommendations tied to the published surface, not compiler guarantees.
Continue reading
Full table of contents