· 6 min read ·

Rust's Conditional Impls: The Pattern Behind Composable Generic APIs

Source: lobsters

Rust’s conditional impl pattern lets you implement a trait for a generic type, but only when that type’s parameters meet certain conditions. The syntax is a where clause on an impl block:

impl<T> Clone for Vec<T>
where
    T: Clone,
{
    fn clone(&self) -> Self {
        self.iter().cloned().collect()
    }
}

Vec<T> only implements Clone when T itself implements Clone. If you have a Vec<SomeNonCloneType>, calling .clone() won’t compile, and the error message tells you exactly which bound is missing. The compiler evaluates these conditions at monomorphization time, so there is no runtime cost.

The consequences of this pattern flow through the entire standard library.

The From/Into Relationship

The cleanest example of conditional impls in practice is the From/Into relationship. The standard library defines Into in terms of From:

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

This single blanket impl means that if you implement From<Foo> for Bar, you get Into<Bar> for Foo for free. You never have to implement both. The condition is that Into<U> for T only applies when U: From<T>.

The alternative would be requiring every crate to implement both traits manually, or having the standard library rely on compiler magic. Instead, the trait system handles it through a conditional impl. This is one reason the Rust community convention is to implement From and not Into directly: you get Into as a consequence.

Iterator and IntoIterator

The Iterator/IntoIterator relationship works the same way:

impl<I> IntoIterator for I
where
    I: Iterator,
{
    type Item = I::Item;
    type IntoIter = I;

    fn into_iter(self) -> I {
        self
    }
}

Every Iterator automatically becomes IntoIterator. This is why you can pass an iterator directly to a for loop, which desugars to calling .into_iter(). The condition is that I must already implement Iterator.

These two examples share a structure: one trait is defined in terms of another, and a conditional impl creates the bridge. It is a form of logical implication at the type level, enforced by the compiler at every call site.

Coherence and the Orphan Rule

This pattern has limits. The Rust compiler enforces coherence: there must be at most one implementation of a trait for any given type. You cannot have two impls of Clone for Vec<T> that apply under different conditions unless those conditions are provably non-overlapping, and the compiler is conservative about what it accepts as provably non-overlapping.

The orphan rule compounds this. To implement a trait for a type, either the trait or the type must be defined in your crate. You cannot implement std::fmt::Display for std::vec::Vec<T>, even conditionally, because both Display and Vec are foreign to your crate. This prevents coherence violations across crate boundaries, but it also means some APIs that seem natural are impossible to express directly.

The standard workaround is the newtype pattern: wrap the foreign type in a local struct and implement the trait on the wrapper. It works, but it adds boilerplate, and you need to re-expose methods from the inner type if you want full parity.

Specialization: A Decade of Nightly

There is a feature that would extend what conditional impls can express: specialization. It would allow a more specific implementation to override a more general one:

#![feature(specialization)]

trait Describe {
    fn describe(&self) -> String;
}

// Applies to any type implementing Debug
impl<T: std::fmt::Debug> Describe for T {
    default fn describe(&self) -> String {
        format!("{:?}", self)
    }
}

// Takes precedence when T is also Clone
impl<T: std::fmt::Debug + Clone> Describe for T {
    fn describe(&self) -> String {
        format!("cloneable: {:?}", self)
    }
}

Specialization has been on nightly Rust since 2015 under #![feature(specialization)]. It remains unstable. The reason is a soundness hole documented in tracking issue #31844: specialization interacts badly with associated types and lifetime parameters. In certain cases, a specialized impl can be selected in a way that allows lifetime constraints to be bypassed, enabling what amounts to lifetime transmutation in safe code. This violates core borrow checker guarantees.

A more limited version, min_specialization, is also available on nightly and is used within the standard library itself for specific optimizations. It disallows specialization on lifetime bounds to sidestep the main soundness hole, but it is not intended for general userland use. Neither version has a clear stabilization timeline, which puts specialization in the unusual position of being a feature the standard library uses but cannot expose to users.

Comparison with Other Languages

Swift added conditional conformance in Swift 4.1, released with Xcode 9.3 in March 2018. The syntax maps closely to Rust:

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

Swift’s model differs in how it handles retroactive conformances. Swift allows a type to conform to a protocol in a different module, with some restrictions, whereas Rust’s orphan rule is stricter. Swift’s flexibility can lead to conformance conflicts when two libraries independently add the same conformance; Rust’s strictness forces the newtype workaround but avoids that class of conflict entirely.

C++20 concepts solve a related problem by constraining template instantiations:

template<typename T>
concept Printable = requires(T t) {
    std::cout << t;
};

template<Printable T>
void print(const T& t) { std::cout << t; }

C++ concepts constrain template parameters but are not attached to a separate impl mechanism with coherence rules. You can have multiple specializations that overlap, with resolution based on specificity. This gives more flexibility at the cost of more potential for surprising behavior. Rust’s model is more rigid but more predictable.

Haskell typeclasses are the intellectual ancestor of Rust traits, and Haskell has supported conditional instances since its early days:

instance (Eq a) => Eq [a] where
    []     == []     = True
    (x:xs) == (y:ys) = x == y && xs == ys
    _      == _      = False

Rust’s trait system was heavily influenced by Haskell’s typeclass system but diverged in coherence rules and in the interaction with ownership semantics. Haskell’s type system does not track ownership, which removes some constraints but also removes the memory safety guarantees that Rust’s traits participate in enforcing.

The Sealed Trait Pattern

One practical application combining conditional impls with a private module restricts which types can implement a trait externally. The sealed trait pattern looks like this:

pub trait Primitive: private::Sealed {}

mod private {
    pub trait Sealed {}

    impl Sealed for u8 {}
    impl Sealed for u16 {}
    impl Sealed for u32 {}
    impl Sealed for u64 {}
}

impl Primitive for u8 {}
impl Primitive for u16 {}
impl Primitive for u32 {}
impl Primitive for u64 {}

External crates can use Primitive as a trait bound, but they cannot implement it because private::Sealed is inaccessible outside the defining crate. Library authors use this when a trait only makes sense for a specific set of types and allowing arbitrary implementations would be unsound or misleading. You see it in crates like tokio for internal extension traits and in parts of the standard library’s unstable API surface.

What This Shapes in Practice

The conditional impl pattern pushes Rust API design toward a specific style: define small, focused traits, then use conditional impls to compose them. A type gets capabilities proportional to what its components support. Option<T> implements Clone when T: Clone, Debug when T: Debug, Default when T: Default, and so on across many more traits. These are separate conditional impls that accumulate without conflict because the coherence rules enforce it.

This composability does not happen automatically; it has to be designed for. Library authors need to think about which traits their generic types should conditionally implement, and they have to work within the coherence constraints. When it is done well, users get a type that integrates into the existing trait ecosystem without explicit effort.

The possiblerust.com article on conditional impls covers the core pattern directly. The broader point is that conditional impls are the mechanism by which Rust’s type system stays coherent as the dependency graph grows. Every where T: Clone in an impl block encodes a logical implication that the compiler verifies at every call site. The cost is a few extra lines of syntax; the benefit is that entire classes of runtime errors become compile-time errors instead, and the coherence rules prevent the kind of silent ambiguity that plagues languages with more permissive dispatch systems.

Was this interesting?