Trait Design Patterns
This chapter explores advanced trait patterns: inheritance and bounds for capabilities, associated types vs generics for API design, trait objects for dynamic dispatch, extension traits for extending external types, and sealed traits for controlled implementation.
Pattern 1: Trait Inheritance and Bounds
- Problem: Expressing complex capability requirements is unclear—a trait needs
Displaybut can’t require it directly. Combining multiple capabilities is verbose (T: Clone + Debug + Display). - Solution: Use supertrait relationships (
trait Loggable: Debug) to express requirements. Use trait bounds in generics (fn process<T: Clone>), andwhereclauses for readability. - Why It Matters: Supertraits create clear capability requirements. Trait bounds allow for powerful composition of abstractions from simple components.
Example: Super Traits
A supertrait is a trait that another trait depends on. When you declare trait Printable: Debug, any type implementing Printable must also implement Debug. This creates a clear dependency chain and guarantees capabilities.
#![allow(unused)]
fn main() {
// Supertrait relationship: Printable requires Debug
trait Printable: std::fmt::Debug {
fn print(&self) {
println!("{:?}", self);
}
}
// Any type implementing Printable must also implement Debug
#[derive(Debug)]
struct Document {
title: String,
content: String,
}
impl Printable for Document {}
// Implementing Printable gives you print() via Debug
let doc = Document {
title: "Rust Guide".into(),
content: "Learning Rust".into()
};
doc.print();
}
Example: Multiple Supertraits
Traits can require multiple supertraits using + syntax, combining different capabilities. A type must implement all supertraits before it can implement the subtrait. This composes behaviors without code duplication.
#![allow(unused)]
fn main() {
use std::fmt::{Debug, Display};
// Requires both Debug and Display
trait Loggable: Debug + Display {
fn log(&self) {
println!("[DEBUG] {:?}", self);
println!("[INFO] {}", self);
}
}
#[derive(Debug)]
struct User {
name: String,
id: u32,
}
impl Display for User {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "User {} (ID: {})", self.name, self.id)
}
}
impl Loggable for User {}
fn use_loggable<T: Loggable>(item: &T) {
item.log();
}
// Loggable requires both Debug and Display implementations
let user = User { name: "Alice".into(), id: 42 };
user.log(); // Prints [DEBUG] and [INFO] lines
use_loggable(&user);
}
This pattern is useful when your abstraction needs multiple orthogonal capabilities. The Loggable trait doesn’t need to know how to debug or display items—it just requires that the capability exists.
Example: Trait Bounds in Generic Functions
Trait bounds specify what capabilities a generic type must have using T: Trait syntax. Multiple bounds use + (e.g., T: Clone + Debug). For complex bounds, where clauses improve readability.
#![allow(unused)]
fn main() {
// Simple bound
fn print_item<T: std::fmt::Display>(item: T) {
println!("{}", item);
}
// Multiple bounds
fn process<T: Clone + std::fmt::Debug>(item: T) {
let copy = item.clone();
println!("Processing: {:?}", copy);
}
// Where clause for readability
fn complex_function<T, U>(t: T, u: U) -> String
where
T: std::fmt::Debug + Clone,
U: std::fmt::Display + Default,
{
format!("{:?} and {}", t, u)
}
// Bounds specify required capabilities for generics
print_item("hello");
process(vec![1, 2, 3]);
let result = complex_function(vec![1, 2], String::new());
}
The where clause improves readability when you have many bounds or complex constraints. It’s especially useful in traits and impl blocks:
#![allow(unused)]
fn main() {
// Note: This example requires the `serde` crate
trait DataProcessor {
fn process<T>(&self, data: T) -> String
where
T: serde::Serialize + std::fmt::Debug;
}
}
Example: Conditional Implementation with Trait Bounds
You can implement traits conditionally based on what traits the type parameters implement. impl<T: Clone> Clone for Wrapper<T> means Wrapper is Clone only when T is Clone. The compiler automatically determines which implementations apply for each concrete type.
#![allow(unused)]
fn main() {
struct Wrapper<T>(T);
// Only implement Clone if T is Clone
impl<T: Clone> Clone for Wrapper<T> {
fn clone(&self) -> Self {
Wrapper(self.0.clone())
}
}
// Only implement Debug if T is Debug
impl<T: std::fmt::Debug> std::fmt::Debug for Wrapper<T> {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "Wrapper({:?})", self.0)
}
}
// Wrapper gains Clone/Debug when inner type has them
let w = Wrapper("hello".to_string());
let w_clone = w.clone(); // Works: String is Clone
println!("{:?}", w); // Works: String is Debug
}
This pattern allows Wrapper<String> to be Clone and Debug. The compiler automatically determines which implementations apply based on the inner type’s capabilities.
Example: Trait Bound Patterns
Several common patterns emerge when working with trait bounds. Methods can be conditionally available based on type parameter bounds. Higher-rank trait bounds (for<'a>) express constraints that must hold for all lifetimes.
#![allow(unused)]
fn main() {
// Builder pattern with trait bounds
struct Query<T> {
data: T,
}
impl<T> Query<T> {
fn new(data: T) -> Self {
Query { data }
}
}
impl<T: Clone> Query<T> {
// Only available if T is Clone
fn duplicate(&self) -> Self {
Query {
data: self.data.clone(),
}
}
}
// Requires serde and serde_json crates
impl<T: serde::Serialize> Query<T> {
// Only available if T is Serialize
fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(&self.data)
}
}
// Higher-rank trait bounds (for all lifetimes)
fn process_with_lifetime<F>(f: F)
where
F: for<'a> Fn(&'a str) -> &'a str,
{
let result = f("hello");
println!("{}", result);
}
// Methods appear based on type parameter's capabilities
let q = Query::new(vec![1, 2, 3]);
let q_dup = q.duplicate();
process_with_lifetime(|s| s);
}
The builder pattern becomes particularly powerful with conditional trait implementations, as methods only appear when the type parameter supports them.
Pattern 2: Associated Types vs Generics
- Problem: A generic trait like
Parser<Output>allows a single type to have multiple implementations (e.g., for differentOutputtypes), which can be confusing. Call sites become verbose (parser.parse::<serde_json::Value>()), and it’s unclear if a type parameter is an “input” or an “output”. - Solution: Use associated types when an implementing type determines a single, specific “output” type (
trait Parser { type Output; }). Use generics when the caller chooses an “input” type and multiple implementations are desirable (trait From<T>). - Why It Matters: This distinction shapes your API’s ergonomics. Associated types eliminate turbofish syntax and let the compiler infer types. Generics enable flexibility but require explicit type annotations. Choosing correctly makes APIs intuitive versus frustrating.
Example: Generics
With generic type parameters, a single type can implement the same trait multiple times for different type arguments. This allows flexibility but requires the caller to specify which implementation to use. The syntax becomes verbose with turbofish (Parser::<i32>::parse).
#![allow(unused)]
fn main() {
// With generics: Multiple implementations possible
trait GenericParser<Output> {
fn parse(&self, input: &str) -> Result<Output, String>;
}
// One type can implement GenericParser for multiple Output types
struct SimpleParser;
impl GenericParser<i32> for SimpleParser {
fn parse(&self, input: &str) -> Result<i32, String> {
input.parse().map_err(|e| format!("{}", e))
}
}
impl GenericParser<bool> for SimpleParser {
fn parse(&self, input: &str) -> Result<bool, String> {
match input {
"true" => Ok(true),
"false" => Ok(false),
_ => Err("invalid bool".to_string()),
}
}
}
// Same parser, multiple Output types; caller specifies
let parser = SimpleParser;
let num: Result<i32, _> =
GenericParser::<i32>::parse(&parser, "42");
let b: Result<bool, _> =
GenericParser::<bool>::parse(&parser, "true");
}
Example: Associated Types: One Implementation
Associated types express “there is one specific type for this implementation.” Each implementor defines exactly one concrete type for the associated type. The compiler infers the output type from the implementor, eliminating the need for turbofish syntax.
#![allow(unused)]
fn main() {
// With associated types: Only one implementation possible
trait Parser {
type Output;
fn parse(&self, input: &str) -> Result<Self::Output, String>;
}
struct IntParser;
impl Parser for IntParser {
type Output = i32;
fn parse(&self, input: &str) -> Result<Self::Output, String> {
input.trim().parse().map_err(|e| format!("{}", e))
}
}
struct FloatParser;
impl Parser for FloatParser {
type Output = f64;
fn parse(&self, input: &str) -> Result<Self::Output, String> {
input.trim().parse().map_err(|e| format!("{}", e))
}
}
// Usage: Each type has exactly one Output; no turbofish needed.
let int_parser = IntParser;
let float_parser = FloatParser;
let num = int_parser.parse("42"); // Output inferred as i32
let flt = float_parser.parse("3.14"); // Output inferred as f64
}
Example: Ergonomics: Associated Types Win for Consumers
Associated types lead to cleaner call sites because the output type is determined by the implementor. With generics, functions need extra type parameters that callers must specify. With associated types, the compiler infers everything from the concrete type.
#![allow(unused)]
fn main() {
// With generic parameter
fn use_generic_parser<T, P: GenericParser<T>>(
parser: P,
input: &str
) -> T {
parser.parse(input).unwrap()
}
// Caller must specify T with turbofish
let num = use_generic_parser::<i32, _>(SimpleParser, "42");
// With associated type
fn use_associated_parser<P: Parser>(
parser: P,
input: &str
) -> P::Output {
parser.parse(input).unwrap()
}
// Associated types let compiler infer Output automatically
let num = use_associated_parser(IntParser, "42"); // i32
let flt = use_associated_parser(FloatParser, "3.14"); // f64
}
Example: When to Use Each
The choice between generics and associated types depends on whether the type parameter is an “input” or “output.” Generics let callers choose the type; associated types let implementors fix it. Here are the guidelines:
Use generics when:
- A type might implement the trait multiple times with different type parameters
- The type parameter is an input to the behavior
- You want flexibility at the call site
#![allow(unused)]
fn main() {
// Generic: Different conversions possible
trait From<T> {
fn from(value: T) -> Self;
}
// String can be created from &str, String, Vec<u8>, etc.
impl From<&str> for String { /* ... */ }
impl From<Vec<u8>> for String { /* ... */ }
}
Use associated types when:
- Only one implementation makes sense for a given type
- The associated type is an output of the behavior
- You want simpler API for consumers
#![allow(unused)]
fn main() {
// Associated type: One iterator type per collection
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
// Vec<i32>'s iterator produces i32, not anything else
}
Example: Combining Both
Sometimes you want both generics and associated types in the same trait. Use generics for inputs that callers choose, and associated types for outputs determined by the implementation. This gives flexibility where needed while keeping the API clean.
#![allow(unused)]
fn main() {
trait Converter<Input> {
type Output;
type Error;
fn convert(&self, input: Input)
-> Result<Self::Output, Self::Error>;
}
struct TemperatureConverter;
impl Converter<f64> for TemperatureConverter {
type Output = f64;
type Error = String;
fn convert(&self, celsius: f64) -> Result<f64, String> {
Ok(celsius * 9.0 / 5.0 + 32.0)
}
}
// Generic Input chosen by caller; Output fixed by impl
let conv = TemperatureConverter;
let fahrenheit = conv.convert(100.0); // Celsius to F
}
Example: Associated Types with Bounds
Associated types can have trait bounds that constrain what types implementors can use. This ensures the associated type has capabilities needed by the trait’s methods. Implementors must choose types that satisfy these bounds.
#![allow(unused)]
fn main() {
trait Graph {
type Node: std::fmt::Display;
type Edge: Clone;
fn nodes(&self) -> Vec<Self::Node>;
fn edges(&self) -> Vec<Self::Edge>;
}
// Implementation must satisfy the bounds
struct SimpleGraph;
impl Graph for SimpleGraph {
type Node = String; // Display ✓
type Edge = (usize, usize); // Clone ✓
fn nodes(&self) -> Vec<String> {
vec!["A".into(), "B".into()]
}
fn edges(&self) -> Vec<(usize, usize)> {
vec![(0, 1)]
}
}
// Associated type bounds: Node is Display, Edge is Clone
let graph = SimpleGraph;
for node in graph.nodes() { println!("{}", node); }
}
Example: The Iterator Pattern Deep Dive
Iterator is the canonical example of associated types done right. Each collection has exactly one item type—Vec<i32> yields i32, not something the caller chooses. This makes iterator chains like .map().filter().collect() ergonomic and type-safe.
#![allow(unused)]
fn main() {
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// Many provided methods using Self::Item
fn count(self) -> usize where Self: Sized { todo!() }
fn map<B, F>(self, f: F) -> Map<Self, F>
where
Self: Sized,
F: FnMut(Self::Item) -> B,
{ todo!() }
}
}
Why associated type instead of generic?
- Each iterator produces one type of item
- The item type is determined by the collection, not chosen by the caller
- Simpler APIs:
Vec<i32>::iter()returns iterator of&i32, not iterator of some genericT
Pattern 3: Trait Objects and Dynamic Dispatch
- Problem: Static dispatch via generics (
fn foo<T: Trait>) creates a copy of the function for each concrete type, leading to code bloat. It’s also impossible to create a collection of different types that share a behavior, likeVec<[Circle, Rectangle]>. - Solution: Use trait objects (
&dyn Trait) for dynamic dispatch. This creates a single version of the function that accepts any type implementing the trait, looking up the correct method at runtime via a vtable. Dynamic dispatch results in smaller binary sizes and allows for runtime polymorphism (e.g., plugin systems). - Why It Matters: This is the key to runtime polymorphism in Rust. Plugin systems, GUI frameworks, and event handlers need heterogeneous collections. The vtable overhead (~2-3ns per call) is negligible for most use cases compared to binary size savings.
Example: static dispatch
With generics, the compiler generates a specialized copy of the function for each concrete type used. This is called monomorphization—process::<i32> and process::<String> become separate functions. The result is fast code but larger binaries.
#![allow(unused)]
fn main() {
use std::fmt::Display;
fn process<T: Display>(item: T) {
println!("{}", item);
}
// Usage: Compiler generates specialized function for each type.
process(42i32); // Generates process::<i32>
process("hello"); // Generates process::<&str>
}
Each call site gets optimized code for that specific type. Fast, but increases binary size (code bloat).
Example: Dynamic dispatch (trait objects):
With trait objects (&dyn Trait), a single function handles all types at runtime. The compiler generates one function that uses a vtable to look up the correct method. This trades a small runtime cost for smaller binary size.
#![allow(unused)]
fn main() {
use std::fmt::Display;
fn process(item: &dyn Display) {
println!("{}", item);
}
// One function handles all types; heterogeneous collections
let num: i32 = 42;
let text: &str = "hello";
process(&num);
let items: Vec<&dyn Display> = vec![&num, &text];
}
One function handles all types. Smaller binary, but slight runtime cost for the vtable lookup.
Example: Creating Trait Objects
Trait objects must be behind a pointer (&dyn, Box<dyn>, Rc<dyn>, Arc<dyn>) because their size is unknown at compile time. This enables heterogeneous collections—a Vec<Box<dyn Drawable>> can hold circles, rectangles, and any other drawable type. Each element is accessed through the trait interface.
#![allow(unused)]
fn main() {
trait Drawable {
fn draw(&self);
}
struct Circle {
radius: f64,
}
impl Drawable for Circle {
fn draw(&self) {
println!("Circle r={}", self.radius);
}
}
struct Rectangle {
width: f64,
height: f64,
}
impl Drawable for Rectangle {
fn draw(&self) {
println!("Rect {}x{}", self.width, self.height);
}
}
fn draw_all(shapes: &[Box<dyn Drawable>]) {
for shape in shapes {
shape.draw();
}
}
// Vec holds different concrete types via trait interface
let shapes: Vec<Box<dyn Drawable>> = vec![
Box::new(Circle { radius: 5.0 }),
Box::new(Rectangle { width: 10.0, height: 20.0 }),
];
draw_all(&shapes);
}
Example: Object Safety Requirements
Not all traits can be made into trait objects—only “object-safe” traits qualify. The compiler must be able to call methods without knowing the concrete type at compile time. These rules ensure the vtable can dispatch all methods correctly.
A trait is “object safe” if:
- No generic methods: Methods cannot have type parameters
#![allow(unused)]
fn main() {
trait NotObjectSafe {
fn generic_method<T>(&self, value: T); // ✗ Generic method
}
// Cannot create &dyn NotObjectSafe
}
- No
Self: Sizedbound: The trait can’t requireSelfto be sized
#![allow(unused)]
fn main() {
trait NotObjectSafe {
fn returns_self(self) -> Self; // ✗ requires Sized
}
}
- No associated functions: Methods must have a
selfreceiver
#![allow(unused)]
fn main() {
trait NotObjectSafe {
fn new() -> Self; // ✗ No self parameter
}
}
The reasoning: when calling a method on a trait object, the compiler doesn’t know the concrete type. Generic methods and associated functions need to know the type at compile time.
Example: Making Traits Object-Safe
You can make traits object-safe with careful design by replacing generics with trait objects. Instead of fn create<T: Serialize>, use fn create(&self, item: &dyn Serialize). Another approach is splitting functionality into separate traits.
#![allow(unused)]
fn main() {
// Not object-safe
trait Repository {
fn create<T: Serialize>(&self, item: T) -> Result<(), Error>;
}
// Object-safe version
trait Repository {
fn create(&self, item: &dyn Serialize) -> Result<(), Error>;
}
// Or split into two traits
trait Repository {
fn create(&self, item: Box<dyn Item>) -> Result<(), Error>;
}
trait Item: Serialize {
// Specific item methods
}
}
This pattern—accepting trait objects instead of generics—makes the trait object-safe while maintaining flexibility.
Example: Downcasting Trait Objects
Sometimes you need to convert a trait object back to a concrete type using std::any::Any. By requiring Any as a supertrait and providing an as_any() method, you can use downcast_ref::<T>(). Use this sparingly—it breaks abstraction.
#![allow(unused)]
fn main() {
use std::any::Any;
trait Shape: Any {
fn area(&self) -> f64;
// Provided method for downcasting
fn as_any(&self) -> &dyn Any {
self
}
}
struct Circle {
radius: f64,
}
impl Shape for Circle {
fn area(&self) -> f64 {
std::f64::consts::PI * self.radius.powi(2)
}
}
fn try_as_circle(shape: &dyn Shape) -> Option<&Circle> {
shape.as_any().downcast_ref::<Circle>()
}
// Downcast from trait object to concrete type
let circle = Circle { radius: 5.0 };
let shape: &dyn Shape = &circle;
if let Some(c) = try_as_circle(shape) {
println!("Radius: {}", c.radius);
}
}
This pattern is useful but breaks abstraction—use it sparingly, only when you truly need concrete type information.
Example: Trait Objects with Lifetime Bounds
Trait objects can have lifetime bounds using dyn Trait + 'a syntax. This specifies how long the concrete type behind the trait object must live. It’s essential when the trait object contains or references borrowed data.
#![allow(unused)]
fn main() {
trait Processor {
fn process(&self, data: &str) -> String;
}
// Trait object with lifetime
fn process_data<'a>(
processor: &'a dyn Processor,
data: &'a str,
) -> String {
processor.process(data)
}
// Boxed trait object with lifetime
struct Handler<'a> {
processor: Box<dyn Processor + 'a>,
}
struct UpperCaseProcessor;
impl Processor for UpperCaseProcessor {
fn process(&self, data: &str) -> String {
data.to_uppercase()
}
}
struct PrefixProcessor<'a> {
prefix: &'a str,
}
impl<'a> Processor for PrefixProcessor<'a> {
fn process(&self, data: &str) -> String {
format!("{}{}", self.prefix, data)
}
}
// Lifetime bounds ensure trait object outlives borrowed data
let prefix = String::from(">>> ");
let prefixer = PrefixProcessor { prefix: &prefix };
let result = process_data(&prefixer, "message");
}
The + 'a syntax means “the trait object must live at least as long as 'a”. This ensures references in the trait implementation remain valid.
Pattern 4: Extension Traits
- Problem: You can’t add methods to types from other crates (the “orphan rule”). You want to extend standard types like
VecorStringwith domain-specific helpers, but can’t modify their source code. - Solution: Define a new trait (an “extension trait”) with the desired methods. Then, implement that trait for the external type.
- Why It Matters: Extension traits let you customize the standard library for your domain without forking code. Crates like
itertoolsadd dozens of iterator methods this way. The pattern enables clean, chainable APIs on any type you don’t own.
Example: Basic Extension Trait
The orphan rule prevents implementing a foreign trait on a foreign type. However, you can implement your own trait on a foreign type—this is the extension trait pattern. Here we define SumExt and implement it for Vec<i32> to add a sum_ext method.
#![allow(unused)]
fn main() {
trait SumExt {
fn sum_ext(&self) -> i32;
}
impl SumExt for Vec<i32> {
fn sum_ext(&self) -> i32 {
self.iter().sum()
}
}
// Extend Vec<f64> too
impl SumExt for Vec<f64> {
fn sum_ext(&self) -> i32 {
self.iter().sum::<f64>() as i32
}
}
// Usage: Extension trait adds sum_ext() method to Vec types.
let numbers = vec![1, 2, 3, 4, 5];
let sum = numbers.sum_ext(); // Returns 15
}
Example: Blanket Iterator Extensions
Define a trait with a supertrait bound (: Iterator) and provide a blanket impl for all I: Iterator. This adds your methods to every iterator in the program without touching any iterator types. The Sized bound on individual methods allows trait object compatibility.
#![allow(unused)]
fn main() {
use std::collections::HashMap;
trait IteratorExt: Iterator {
// Count occurrences of each item in an iterator.
fn counts(self) -> HashMap<Self::Item, usize>
where
Self: Sized,
Self::Item: Eq + std::hash::Hash,
{
let mut map = HashMap::new();
for item in self {
*map.entry(item).or_insert(0) += 1;
}
map
}
}
// Blanket impl: applies to any type that is an Iterator.
impl<I: Iterator> IteratorExt for I {}
// Blanket impl gives counts() to all iterators automatically
let words = vec!["apple", "banana", "apple"];
let counts = words.into_iter().counts();
}
Example: Ergonomic Error Handling
Extension traits can add context or logging to the standard Result type. Here ResultExt provides a log_err method that logs errors before passing them up the call stack. This pattern is used extensively in libraries like anyhow for error context chaining.
#![allow(unused)]
fn main() {
trait ResultExt<T> {
fn log_err(self, context: &str) -> Self;
}
impl<T, E: std::error::Error> ResultExt<T> for Result<T, E> {
fn log_err(self, context: &str) -> Self {
self.map_err(|e| {
eprintln!("[ERROR] {}: {}", context, e);
e
})
}
}
// Add context method for more info
trait ResultContextExt<T, E> {
fn with_context(self, msg: &str) -> Result<T, String>;
}
impl<T, E: std::fmt::Display> ResultContextExt<T, E>
for Result<T, E> {
fn with_context(self, msg: &str) -> Result<T, String> {
self.map_err(|e| format!("{}: {}", msg, e))
}
}
// Extension adds with_context() to Result for error chaining
let result: Result<i32, &str> = Err("not found");
let contexted = result.with_context("Loading user");
}
Example: Extending Standard Types
You can add domain-specific helper methods to standard library types like String and str. Using generic bounds like T: AsRef<str> makes the extension work for multiple string types. The trait must be in scope to use the extended methods.
#![allow(unused)]
fn main() {
trait StringExt {
fn truncate_to(&self, max_len: usize) -> String;
}
impl<T: AsRef<str>> StringExt for T {
fn truncate_to(&self, max_len: usize) -> String {
let s = self.as_ref();
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len.saturating_sub(3)])
}
}
}
// Usage: Extension adds truncate_to() to all string-like types.
let s = "This is a long string that needs truncation";
let truncated = s.truncate_to(20); // "This is a long st..."
}
Example: Conditional Extensions
An extension can be conditional on the capabilities of the type being extended. This DebugExt trait is implemented for any T: Debug, giving all debuggable types a debug_print method. The blanket impl impl<T: Debug> DebugExt for T automatically covers thousands of types.
#![allow(unused)]
fn main() {
trait DebugExt {
fn debug_print(&self);
}
impl<T: std::fmt::Debug> DebugExt for T {
fn debug_print(&self) {
println!("{:?}", self);
}
}
// Usage: All Debug types automatically get debug_print() method.
let numbers = vec![1, 2, 3];
numbers.debug_print(); // Prints "[1, 2, 3]"
}
Pattern 5: Sealed Traits
- Problem: As a library author, you want to publish a trait that users can depend on, but you want to prevent them from implementing it themselves. This allows you to add new methods to the trait later without it being a breaking change.
- Solution: Create a private
sealedmodule with a public but un-implementableSealedtrait. Make your public trait a supertrait ofsealed::Sealed. - Why It Matters: Sealed traits enable API evolution without semver breakage. Adding a required method to an open trait is a breaking change; with sealed traits, you control all implementations. This is essential for stable, long-lived library APIs.
Example: Basic Sealed Trait
A sealed trait uses a private supertrait to prevent external implementations. The sealed::Sealed trait is public but in a private module, so external crates can’t access it. This lets library authors add methods without breaking changes.
#![allow(unused)]
fn main() {
mod sealed {
pub trait Sealed {}
}
pub trait MyTrait: sealed::Sealed {
fn my_method(&self);
// Can add new methods without breaking external code
fn new_method(&self) {
println!("Default implementation");
}
}
struct MyType {
value: i32
}
// Must implement Sealed first (only possible within this crate)
impl sealed::Sealed for MyType {}
impl MyTrait for MyType {
fn my_method(&self) {
println!("Value: {}", self.value);
}
}
struct AnotherType;
impl sealed::Sealed for AnotherType {}
impl MyTrait for AnotherType {
fn my_method(&self) {
println!("AnotherType impl");
}
}
// External crates can USE MyTrait but cannot IMPLEMENT it
fn use_trait<T: MyTrait>(item: &T) {
item.my_method();
item.new_method();
}
// Usage: External crates can use MyTrait but cannot implement it.
let my = MyType { value: 42 };
my.my_method(); // Prints "Value: 42"
my.new_method(); // Default implementation works
}
Example: Dependency Injection with Traits
Use traits to define interfaces for external services like databases and email. Your code depends on trait bounds, not concrete types. This enables easy mocking in tests and swapping implementations in production.
#![allow(unused)]
fn main() {
trait Database {
fn get_user(&self, id: i32) -> Option<User>;
fn save_user(&self, user: &User) -> Result<(), Error>;
}
trait EmailService {
fn send_email(
&self,
to: &str,
subject: &str,
body: &str,
) -> Result<(), Error>;
}
struct UserService<D, E> {
database: D,
email: E,
}
impl<D: Database, E: EmailService> UserService<D, E> {
fn new(database: D, email: E) -> Self {
UserService { database, email }
}
fn register_user(
&self,
name: &str,
email: &str,
) -> Result<User, Error> {
let user = User {
id: generate_id(),
name: name.to_string(),
email: email.to_string(),
};
self.database.save_user(&user)?;
self.email.send_email(email, "Welcome!", "Thanks")?;
Ok(user)
}
}
// Supporting types
#[derive(Clone, Debug, PartialEq)]
struct User { id: i32, name: String, email: String }
#[derive(Debug)]
struct Error;
fn generate_id() -> i32 { 1 }
// Mock implementations for testing
use std::cell::RefCell;
struct MockDb { users: RefCell<Vec<User>> }
struct MockEmail { sent: RefCell<Vec<String>> }
impl Database for MockDb {
fn get_user(&self, id: i32) -> Option<User> {
self.users.borrow().iter()
.find(|u| u.id == id).cloned()
}
fn save_user(&self, user: &User) -> Result<(), Error> {
self.users.borrow_mut().push(user.clone());
Ok(())
}
}
impl EmailService for MockEmail {
fn send_email(
&self, to: &str, _: &str, _: &str
) -> Result<(), Error> {
self.sent.borrow_mut().push(to.to_string());
Ok(())
}
}
// Trait bounds enable swapping real services for mocks in tests
let db = MockDb { users: RefCell::new(vec![]) };
let email = MockEmail { sent: RefCell::new(vec![]) };
let service = UserService::new(db, email);
let user = service.register_user("Alice", "a@b.com");
}
Summary
Key Takeaways:
- Trait inheritance expresses capabilities: “to be A must be B” is declarative and composable
- Associated types = one impl per type, inferred; generics = multiple impls, explicit choice
- Dynamic dispatch = smaller binary, ~2-3ns overhead; static dispatch = optimized per-type
- Extension traits extend types you don’t own via trait + impl
- Sealed traits prevent external impls via private supertrait
Design Guidelines:
- Supertraits for capability requirements:
trait Loggable: Debug + Display - Associated types when output determined by type, generics when chosen by caller
- Trait objects for heterogeneous collections, generics for performance
- Extension traits for opt-in functionality on external types
- Sealed traits when evolution/safety requires controlled implementations
Object Safety Rules (for &dyn Trait):
- No generic methods (needs concrete type at compile-time)
- No Self: Sized bound (trait objects are !Sized)
- Must have &self/&mut self receiver (needs object to call)
- No associated functions without self (can’t call without type)