Conditional Impls: Capability Propagation Through Rust's Type System
Source: lobsters
The core idea behind conditional impls is simple to state: a generic type should automatically acquire a capability whenever all of its type parameters have that capability. The interesting part is what that means in practice, what it compares to in other languages, and where Rust’s current model breaks down.
The possiblerust.com treatment of conditional impls covers the mechanics well. This post takes a different angle: conditional impls as a form of capability propagation, where bounds on impl blocks let you encode type-level if/then logic that flows through your entire type hierarchy automatically.
The Basic Mechanism
Consider a simple generic wrapper:
struct Wrapper<T>(T);
impl<T: Clone> Clone for Wrapper<T> {
fn clone(&self) -> Self {
Wrapper(self.0.clone())
}
}
This is not a blanket impl. Wrapper<T> is not Clone for all T. It is Clone only when T: Clone. The bound on the impl block is the condition; the trait being implemented is the consequence. If T does not implement Clone, Wrapper<T> does not implement Clone either, and the compiler enforces this without any runtime check.
That propagation is the key insight. When you write a generic type and conditionally implement standard traits on it, you are telling the compiler: “whatever capabilities T has, Wrapper<T> should inherit.” The type system reasons about this statically, at the call site, not at runtime.
The Standard Library Does This Pervasively
The Rust standard library applies this pattern throughout its core collection types. Vec<T> implements Clone only when T: Clone. The implementation is roughly:
impl<T: Clone> Clone for Vec<T> {
fn clone(&self) -> Self {
self.iter().cloned().collect()
}
}
Option<T>, Result<T, E>, Box<T>, Arc<T>, and most generic types in std follow the same pattern. This means that composing these types preserves their capabilities correctly. Vec<Arc<Mutex<T>>> is Clone when T: Clone because Arc<Mutex<T>> is Clone regardless of T, but Vec<Box<File>> is not Clone because File is not Clone. You never have to think about this; the type system figures it out during monomorphization.
The same pattern applies to Debug, PartialEq, PartialOrd, Hash, and Default. Each of those traits has conditional impls on the standard containers. The result is a consistent, transitive capability graph: if your leaf types implement standard traits, the composed types do too, without any manual glue.
How C++ Handles the Same Problem
C++ has wrestled with conditional interface implementation for its entire history. The original approach was SFINAE (Substitution Failure Is Not An Error), where template specialization exploits the rule that a substitution failure during template instantiation is not a compile error but rather a quiet elimination of that overload from consideration. Code that relied on SFINAE was notoriously difficult to read, since the conditions were expressed as arcane enable_if expressions buried in template parameter lists or return types.
C++20 concepts improved this substantially. A concept is a named predicate on types, and you can constrain template parameters with requires clauses that read far more clearly than SFINAE. The rough equivalent of impl<T: Clone> Clone for Wrapper<T> in C++20 would be a constrained specialization using requires std::copyable<T>. The mechanism is different, but the intent is similar: conditionally expose an interface based on what the component types support.
The key difference is that Rust separates the conditional impl cleanly from the struct definition. In C++, the struct and its behavior are intertwined in the template class body. Rust’s trait system means you can write the struct once, then write each trait impl separately with its own bounds. The conditions are explicit and local to each impl block, not scattered through a unified template definition.
How Haskell Handles It
Haskell’s typeclass system handles this through instance constraints. To derive Eq for a parameterized type, you write:
instance Eq a => Eq (Wrapper a) where
(Wrapper x) == (Wrapper y) = x == y
The Eq a => prefix is the condition. This is structurally identical to Rust’s impl<T: Eq> Eq for Wrapper<T>. Haskell arrived at this design decades before Rust, and Rust’s trait system is explicitly influenced by Haskell’s typeclasses.
The difference is that Haskell’s instances are open and global in a way that Rust’s impls are not. Haskell has the orphan instance problem in a form that can affect runtime behavior; Rust has a stricter coherence system that prevents duplicate impls but has its own consequences, particularly for the specialization story discussed below.
The PhantomData Derive Problem
One place where conditional impls become practically important is the interaction between #[derive] and PhantomData. This is a real footgun that affects anyone building zero-cost abstraction types.
Consider:
use std::marker::PhantomData;
#[derive(Clone)]
struct Foo<T> {
value: i32,
_phantom: PhantomData<T>,
}
The #[derive(Clone)] macro generates an impl that adds a T: Clone bound even though PhantomData<T> is always Clone regardless of T, and even though i32 is always Clone. The generated impl looks like:
impl<T: Clone> Clone for Foo<T> {
fn clone(&self) -> Self {
Foo {
value: self.value.clone(),
_phantom: self._phantom.clone(),
}
}
}
This is overly restrictive. Foo<T> should be Clone for all T, because none of its actual data depends on T being cloneable. The correct manual implementation is:
impl<T> Clone for Foo<T> {
fn clone(&self) -> Self {
Foo {
value: self.value,
_phantom: PhantomData,
}
}
}
This is the “perfect derive” problem: #[derive] adds bounds on every type parameter appearing in the struct, regardless of whether those bounds are actually needed. For types using PhantomData as a marker, this creates spurious constraints that propagate upward through every type that contains Foo<T>, unnecessarily restricting what those types can do.
The standard library works around this by writing manual impls for many of its types. User-facing library code often has to do the same when PhantomData is involved. There are proposals for a “perfect derive” mechanism that would analyze actual bounds requirements rather than naively adding bounds for all type parameters, but no stable solution exists yet.
Auto Traits as the Extreme Case
Auto traits are the most powerful form of conditional impl because they propagate automatically without any explicit impl block at all. Send and Sync are the canonical examples, and their behavior is described in the Rust Reference’s section on special types and traits.
Send means a type is safe to transfer across thread boundaries. Sync means a shared reference to the type is safe to send across thread boundaries. The compiler determines these properties structurally: a type is Send if all of its fields are Send, and Sync if all of its fields are Sync. You never write impl<T: Send> Send for Vec<T>. The compiler generates this analysis automatically.
The corollary is that you can opt out with explicit negative impls. Rc<T> is neither Send nor Sync because it uses non-atomic reference counting. The standard library has an explicit impl !Send for Rc<T> to block the auto-derivation that would otherwise be incorrect. This gives you a two-level system: automatic propagation as the default, explicit negative impls to override where the structural analysis produces the wrong answer.
Auto traits represent the logical endpoint of the conditional impl idea. Rather than writing impl<T: Send> Send for MyType<T>, the compiler writes it for you, then you override specific cases where the automation gets it wrong. The result is that almost all user-defined types get correct Send and Sync impls for free, with unsafe escape hatches for the cases that require manual reasoning about memory safety across threads.
Where Conditional Impls Fall Short: Specialization
The current conditional impl system has a significant gap: you cannot write a “default” implementation that applies broadly, alongside a more specific implementation that overrides it for certain types. Every impl must be non-overlapping with every other impl for the same trait.
This limitation matters in practice. Suppose you want a ToString impl that works for all types with Display, but a specialized faster version for types that are already String. You cannot express this today. The two impls would overlap, and the compiler rejects overlapping impls.
RFC 1210, the specialization RFC, proposed solving this with a default keyword on impl items. A default fn could be overridden by a more specific impl, giving you controlled overlap. The RFC was merged in 2015 and partially implemented under the specialization feature flag, but it has remained unstable ever since. The soundness issues turned out to be deeper than originally understood: specialization interacts with lifetime variance in ways that can allow unsound code, and no complete fix has landed.
A narrower proposal called min_specialization restricts the feature to cases that can be proven sound, and the standard library uses it internally behind a feature gate. User code on stable cannot access any of this.
The consequence is that library authors frequently have to choose between a correct but slow generic implementation and a fast but non-generic one, with no way to provide both and let the compiler pick. The standard library has worked around some of these cases with internal traits and specialization-like tricks, but these are not patterns available to stable user code.
Putting It Together
Conditional impls are Rust’s mechanism for encoding type-level if/then reasoning: if the type parameter has this capability, then the containing type does too. The pattern threads through the entire standard library, through Vec, Option, Arc, and dozens of other types, each of which conditionally implements Clone, Debug, PartialEq, and others based solely on what their type parameters support.
C++ reached a similar place via SFINAE and then concepts, with more syntactic friction and tighter coupling between struct definition and interface. Haskell got there first with instance constraints that are semantically near-identical to Rust’s conditional impls. The auto trait system takes the idea further still, making the propagation fully automatic and requiring only opt-outs rather than opt-ins.
The remaining rough edges, spurious bounds from #[derive] on PhantomData-containing types and the absence of specialization for overlapping impls, are real constraints that show up in library design. Both are understood problems with stalled solutions. Until those land, writing correct generic libraries sometimes requires manual impls where derive would add unnecessary bounds, and accepting that “default behavior with override” is simply not expressible in stable Rust.