Traits

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