Trait Design and API Contracts
Designing clean, future-proof traits in Rust requires choosing the right granularity of required methods, deciding between associated types and generic parameters, preserving dyn compatibility when needed, and respecting semantic contracts across implementations.
Minimal Required Core + Rich Default Methods
A well-designed trait requires implementors to write only the irreducible primitive operation and builds convenience methods as default implementations on top of that core:
pub trait KeyValueStore {
// Minimal core required from implementors:
fn get_bytes(&self, key: &str) -> Option<&[u8]>;
fn set_bytes(&mut self, key: &str, value: Vec<u8>);
// Convenience methods provided automatically:
fn contains_key(&self, key: &str) -> bool {
self.get_bytes(key).is_some()
}
fn get_utf8(&self, key: &str) -> Option<&str> {
std::str::from_utf8(self.get_bytes(key)?).ok()
}
}
struct InMemoryKv(Vec<(String, Vec<u8>)>);
impl KeyValueStore for InMemoryKv {
fn get_bytes(&self, key: &str) -> Option<&[u8]> {
self.0
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_slice())
}
fn set_bytes(&mut self, key: &str, value: Vec<u8>) {
self.0.push((key.to_owned(), value));
}
}
fn main() {
let mut store = InMemoryKv(Vec::new());
store.set_bytes("region", b"ap-south-1".to_vec());
println!("Has region: {}", store.contains_key("region"));
println!("Region str: {:?}", store.get_utf8("region"));
}
Has region: true
Region str: Some("ap-south-1")
Semantic Contracts Not Checked by the Type System
The compiler checks method signatures, lifetimes, and trait bounds, but several critical trait contracts are semantic invariants that implementors must uphold manually:
| Trait Pair | Semantic Contract | Consequence of Violation |
|---|---|---|
PartialEq and Hash |
a == b must imply hash(a) == hash(b) |
HashMap and HashSet fail to find keys that compare equal |
PartialOrd and Ord |
a.partial_cmp(&b) must equal Some(a.cmp(&b)) |
BTreeMap and slice::sort() produce corrupted ordering |
Borrow<Q> |
Eq, Ord, and Hash on Q must match those on Self |
HashMap::get(&str) on a HashMap<String, V> returns wrong lookups |
AsRef<U> / From<T> |
Must be infallible and preserve value equivalence | Panicking inside as_ref() or from() violates caller expectations |
SemVer Compatibility When Evolving Public Traits
Once a public trait is released, downstream crates may implement it on their own types. Modifying the trait in a later release has strict SemVer implications:
| Change to a Public Trait | SemVer Impact | Reason / Mitigation |
|---|---|---|
Adding a new required method (fn foo(&self);) |
Breaking change | Existing downstream impl blocks fail with E0046 |
Adding a new default method (fn foo(&self) { ... }) |
Minor (non-breaking) | Downstream impl blocks inherit the default automatically (minor risk of name collision) |
| Removing or changing a method signature | Breaking change | Breaks both implementors and callers |
Adding a new supertrait bound (trait A: NewSuper) |
Breaking change | Downstream implementors of A may not implement NewSuper |
Keeping Traits dyn-Compatible by Gating Generic Helpers
If a trait is intended to be used as a trait object (&dyn Trait or Box<dyn Trait>), adding a generic method (fn serialize<W: Write>(&self, w: &mut W)) or a by-value self method (fn into_inner(self)) to the trait makes the entire trait non-dyn-compatible unless that method is gated with where Self: Sized:
pub trait Plugin {
fn name(&self) -> &str;
// Gated with `where Self: Sized` so `&dyn Plugin` and `Box<dyn Plugin>` remain valid:
fn boxed(self) -> Box<dyn Plugin>
where
Self: Sized + 'static,
{
Box::new(self)
}
}
struct AuthPlugin;
impl Plugin for AuthPlugin {
fn name(&self) -> &str {
"auth-v2"
}
}
fn main() {
let plugins: Vec<Box<dyn Plugin>> = vec![AuthPlugin.boxed()];
println!("Loaded plugin: {}", plugins[0].name());
}
Loaded plugin: auth-v2