· 7 min read ·

The Propagation Principle: How Conditional Impls Make Rust Generics Composable

Source: lobsters

One pattern that separates idiomatic Rust generics from naive ones is the conditional impl. When you define a generic struct, you often want it to be clonable if its contents are clonable, or serializable if its type parameters are serializable. The naive approach is to add those bounds to the struct definition itself. The better approach is to let capabilities flow naturally through the type system, adding them only in the impl blocks where they can actually be satisfied.

The possiblerust.com article on conditional impls frames this as a deliberate design pattern, and it is, but it is also something deeper: it is the mechanism by which Rust’s type system propagates properties through composition without runtime overhead or boilerplate.

The Problem with Unconditional Bounds

Consider a simple wrapper type:

struct Cache<T> {
    value: T,
    hits: u64,
}

You want Cache<T> to implement Clone. The instinct is to put the bound on the struct:

struct Cache<T: Clone> {
    value: T,
    hits: u64,
}

But now Cache<TcpStream> is illegal, because TcpStream does not implement Clone. You have made the type more restrictive than it needs to be. Users who never need to clone their cache are punished for a capability that only some users need.

The alternative is a conditional impl: implement Clone for Cache<T> in a separate impl block, with the bound living on that block rather than on the struct:

impl<T: Clone> Clone for Cache<T> {
    fn clone(&self) -> Self {
        Cache {
            value: self.value.clone(),
            hits: self.hits,
        }
    }
}

Now Cache<String> is Clone and Cache<TcpStream> is not, and both are usable. The capability appears exactly where it can be provided and nowhere else.

The Standard Library Does This Throughout

This pattern is woven into the standard library’s generic types. Vec<T> implements Clone only when T: Clone. Option<T> implements Copy only when T: Copy. Result<T, E> implements Clone only when both T: Clone and E: Clone.

The iterator adapter types make this especially clear. The Map adapter, which wraps an iterator and applies a function to each element, carries multiple conditional impls:

// Iterator is unconditional on Map
impl<B, I: Iterator, F: FnMut(I::Item) -> B> Iterator for Map<I, F> {
    type Item = B;
    fn next(&mut self) -> Option<B> {
        self.iter.next().map(&mut self.f)
    }
}

// DoubleEndedIterator is conditional
impl<B, I: DoubleEndedIterator, F: FnMut(I::Item) -> B> DoubleEndedIterator for Map<I, F> {
    fn next_back(&mut self) -> Option<B> {
        self.iter.next_back().map(&mut self.f)
    }
}

// ExactSizeIterator is also conditional
impl<B, I: ExactSizeIterator, F: FnMut(I::Item) -> B> ExactSizeIterator for Map<I, F> {}

Map implements DoubleEndedIterator only when the underlying iterator does. If you map over a slice’s iterator, which is double-ended, you can call .rev() on the result. If you map over a Lines iterator, which is not double-ended, you cannot. The compiler enforces this without any runtime branching or dynamic dispatch.

The propagation chains through arbitrary depth. A Chain<Map<Enumerate<Zip<A, B>>, F>, C> will implement ExactSizeIterator if and only if every component in that chain can guarantee its exact size. The capabilities compose through the type system.

Blanket Impls: The Extreme Case

A blanket impl is a conditional impl that applies to all types meeting a bound, not just to a specific struct’s type parameter. The most consequential blanket impl in the standard library is the relationship between From and Into:

impl<T, U> Into<U> for T
where
    U: From<T>,
{
    fn into(self) -> U {
        U::from(self)
    }
}

For any pair of types where U: From<T>, the compiler automatically provides T: Into<U>. You implement From and the blanket impl handles Into. This is why the Rust convention is to implement From and accept impl Into<T> in APIs: callers who have From implementations get Into for free.

Another pervasive blanket impl connects Display and ToString:

impl<T: fmt::Display> ToString for T {
    fn to_string(&self) -> String {
        format!("{}", self)
    }
}

Anything that implements Display gets to_string() without writing additional code. The ToString trait exists mostly as a bound, a way to say “things that can become a string,” but you satisfy it through Display.

How Other Languages Handle the Same Problem

The idea of conditionally propagating capabilities through generic containers appears in every language with a serious generics system, but the mechanisms differ.

Haskell has had this since its inception, and the syntax maps almost directly onto Rust’s:

instance (Eq a) => Eq (Maybe a) where
    Nothing == Nothing = True
    Just x  == Just y  = x == y
    _       == _       = False

Maybe a is Eq if a is Eq. Rust’s equivalent is impl<T: PartialEq> PartialEq for Option<T>. The structure is the same; the surface syntax differs. Haskell’s type class system has no hard orphan rule at the language level (though GHC warns about overlapping instances), which creates a class of problems Rust avoids.

Swift added conditional conformances in Swift 4.2 after years of it being one of the most-requested language features. The syntax is:

extension Array: Equatable where Element: Equatable {
    static func == (lhs: Array, rhs: Array) -> Bool {
        guard lhs.count == rhs.count else { return false }
        return zip(lhs, rhs).allSatisfy(==)
    }
}

Swift’s version is more limited than Rust’s in some edge cases involving protocol composition and generic specialization, but for everyday use it works identically. Before Swift 4.2, you could not express that [Int] was Equatable at the type system level, even though Int was, which led to awkward workarounds.

C++20 addresses the same need through concepts and requires-clauses:

template<typename T>
class Wrapper {
public:
    Wrapper clone() const requires std::copyable<T> {
        return Wrapper{value};
    }
private:
    T value;
};

C++ applies the condition at the method level rather than the impl block level, which means the structure of the enabled capability is less visible in the type signature. Rust’s separate impl blocks make it explicit that the entire set of methods in that block comes as a unit, conditional on the same set of constraints.

Coherence and the Orphan Rules

Conditional impls interact with Rust’s coherence rules. For any concrete type, at most one implementation of a given trait can exist. If two crates both tried to define impl<T: Clone> Serialize for Vec<T>, the compiler would have no principled way to choose between them. Rust prevents this with the orphan rule: you can only implement a trait for a type if either the trait or the type is defined in your crate.

Blanket impls tighten this further. The From/Into blanket means you cannot implement Into<MyType> for String directly; you must implement From<String> for MyType instead. More subtly, if a crate defines impl<T: SomeExternalTrait> Into<ExternalType> for T, and your type implements SomeExternalTrait, you might find yourself unable to add a separate Into<ExternalType> impl for your type because the blanket already covers it. The compiler catches this, but it can be surprising when you encounter it.

The fundamental challenge is that determining whether two impl blocks can overlap for any possible type is undecidable in the general case. Rust’s coherence checker is conservative, which means it sometimes rejects combinations of impls that would not actually conflict. There is long-running work on specialization (RFC 1210), which would allow one impl to specialize another more specific impl, but the RFC has stalled on soundness concerns around lifetimes and has not stabilized.

Auto Traits: Conditional Impls Generated by the Compiler

Auto traits are the furthest extension of this idea. Traits like Send and Sync are implemented automatically by the compiler for any type whose fields all implement those traits. You do not write impl Send for MyStruct; the compiler generates it based on structural inspection.

Mutex<T> is Send when T: Send because the standard library defines it that way, but your own structs get Send and Sync for free when all their fields have those properties. You can opt out with a negative impl (impl !Send for MyType) or add Send explicitly for unsafe wrapper types like raw pointer containers.

This means safety properties propagate structurally through types automatically, which is central to how Rust’s threading model works. A type that wraps a Rc<T> is not Send because Rc is not Send, and the compiler catches cross-thread uses of that type without any explicit annotation on the wrapping type.

API Design Consequences

When designing a generic API in Rust, the relevant question shifts from “what can this type do” to “what can this type do given what its type parameters can do.” A Pool<T> should be Clone when T: Clone, Debug when T: Debug, and Send automatically when T: Send. Each capability belongs in its own conditional impl block, and none of them should appear as unconditional bounds on the struct definition.

The payoff for callers is that capabilities appear wherever the underlying types support them, without any extra work. A function that accepts impl Iterator can chain .rev() on the result only if the iterator is also DoubleEndedIterator, and the type system tracks which iterators qualify. There are no runtime surprises, no panics on capability mismatches, and no need for the caller to manually propagate trait bounds they might forget.

The discipline is to write impl blocks that accurately reflect what is possible. If Clone on your type requires Clone on its contents, the bound belongs on the Clone impl, not on the struct. The type becomes more flexible, the API remains precise, and the capability propagation happens without any runtime overhead.

That precision is what makes the pattern worth internalizing. Generic Rust code often feels like it is fighting you until you understand how capabilities are supposed to flow, and then it starts to feel like the type system is doing real work on your behalf.

Was this interesting?