Puzzling Behavior with `impl Trait` in Builder Pattern Method Chaining

Hello Rust community,

I've been implementing a builder pattern with complex generic types and encountered a puzzling compiler behavior that I can't explain. I've created a minimal reproducible example that demonstrates the issue.

The Problem

I have a builder pattern where chaining two methods (build_b followed by build_last) works perfectly. However, when I try to encapsulate this chain in a single method (build), I get trait bound errors. What's particularly confusing is that creating a new instance and chaining methods works in a helper function, but using the passed-in instance fails with the same error.

Complete Example

Here's a minimal example demonstrating the issue:

pub trait Call<Input> {
    type Output;
    fn docall(&self, input: Input) -> Self::Output;
}

struct ToAddCall;

impl<T> Call<T> for ToAddCall {
    type Output = AddCall;
    fn docall(&self, _input: T) -> Self::Output {
        AddCall(1)
    }
}

struct AddCall(i32);

impl Call<i32> for AddCall {
    type Output = i32;

    fn docall(&self, input: i32) -> Self::Output {
        input + self.0
    }
}

pub struct A<Data1>(Data1);

fn build<C1, C2, C3>(this: A<C1>, input: i32) -> C2::Output
where
    C1: Call<C2, Output = C3>,
    C2: Call<i32, Output = i32>,
    C3: Call<i32, Output = i32>,
{
    // This doesn't work:
    // this.build_b().build_last(input) // <-- the trait bound is not satisfied)

    // But creating a new instance and chaining works!
    drop(this);
    let this = A(ToAddCall);
    this.build_b().build_last(input)
}

impl<C1> A<C1> {
    pub fn build<C2, C3>(self, input: i32) -> i32
    where
        C1: Call<C2, Output = C3>,
        C2: Call<i32, Output = i32>,
        C3: Call<i32, Output = i32>,
    {
        // This doesn't work:
        // self.build_b().build_last(input) // <-- the trait bound is not satisfied

        // Delegating to a helper function works, but it doesn't use the passed instance
        build::<C1, C2, C3>(self, input)
    }

    pub fn build_b(self) -> B<C1, impl Call<i32, Output = i32>> {
        B(self.0, AddCall(1))
    }
}

pub struct B<C1, C2>(C1, C2);

impl<C1, C2> B<C1, C2> {
    pub fn build_last<C3, Input2>(self, input: Input2) -> C3::Output
    where
        C1: Call<C2, Output = C3>,
        C3: Call<Input2>,
    {
        self.0.docall(self.1).docall(input)
    }
}

#[test]
fn test() {
    // Direct chaining works perfectly
    let result = A(ToAddCall).build_b().build_last(10);
    assert_eq!(result, 11);
  
    // Without type annotations doesn't work
    // let result = A(ToAddCall).build(10); // <-- type annotation needed
  
    // With type annotations works
    let result = A(ToAddCall).build::<AddCall, AddCall>(10);
    assert_eq!(result, 11);
}

Key Observations

  1. Direct method chaining works perfectly:

    • A(ToAddCall).build_b().build_last(10) compiles and runs without issue
  2. Inside helper function build:

    • Using the passed instance with this.build_b().build_last(input) fails
    • Creating a new instance A(ToAddCall).build_b().build_last(input) works
  3. Inside A::build method:

    • Chaining directly with self.build_b().build_last(input) fails
    • The error is: the trait bound 'C1: Call<impl Call<i32, Output = i32>>' is not satisfied
    • But the generic constraints is matched: C1: Call<C2, Output=C3>, C2: Call<i32, Ouput=i32>
  4. Using the build method requires explicit type annotations:

    • A(ToAddCall).build(10) fails with type inference errors
    • A(ToAddCall).build::<AddCall, AddCall>(10) works correctly

Real World Use Case

In my actual codebase, I have a more complex builder pattern where users currently need to chain build_b().build_last(). I want to provide a cleaner API by offering a single build() method that encapsulates this chain, hiding implementation details.

The problem is that I can't get this to work without requiring explicit type annotations, which defeats the purpose of API simplification. The real types are much more complex, so I can't just replace impl Trait with concrete types.

Questions

  1. Why does method chaining directly work, but attempting to encapsulate the chain in a method fails?
  2. Why does a helper function work when creating a new instance, but fail when using the passed instance?
  3. What's happening with the impl Trait that prevents it from working with generic constraints in this scenario?
  4. Is there a way to encapsulate this chain in a single method without requiring explicit type annotations?

I've been puzzled by this behavior for days and would greatly appreciate any insights into what's going on here!

You can find the complete code in this Rust Playground: Rust Playground

What does build1 look like as used in A::build? Also what does the comment mean:

// Delegating to a helper function works, but it doesn't use the passed instance

since it appears to be passed self and input?

Sorry for confusing, it's a typo for build, which is remained when I did some experiments.

The ::build function that indeed accepts self as it's this parameter, and It not errored in the call site, but in ::build function, if I actually use the this parameter, the same error are out (trait bound is not satisfied), but if I dropped the this parameter and create a new instance of A, and it just works.

The real issue confusing me is that the self.build_b() returns a B<C1, impl Add<i32, Output=i32>>, and the C1 is just a generic type that implements Call on any T. But I cannot call the B::build_last because compiler thinks that C1 doesn't implements Call<impl Call<i32>>? But why?

The compilation error message is pretty useful. In A::build you only know C1: Call<C2, Output = C3>; however C2 is determined by calling code so you must use the type passed to it and not some other type (e.g., AddCall) that has the same bound.

C2 is what's called a universally-quantified type parameter whose universal quantifier is scoped over the entire function; thus as a library author, you don't control C2 at all. In the body of the function, you are using AddCall not C2 but you only know that C1 works for C2.

If C1 were instead a lifetime, then you might be able to use higher-rank trait bounds (HRTBs) to bound C1 such that it works for any type allowing you to use AddCall (or any other type) and not only use the type passed to the function.

It is effectively the same reason the below code is not valid:

fn foo<T: PartialEq<u32>, T2: PartialEq<T>>(x: T2) -> bool {
    x == 10u32
}

Just because I know T2: PartialEq<T> and T: PartialEq<u32> doesn't mean I can substitute any type that implements PartialEq<u32> (e.g., u32) when testing for equivalence with x.

4 Likes

Perhaps you're getting confused on what return position impl traits (RPITs) are; if so, then I suggest you read the blog I linked to above.

While the blog explains that RPITs are technically "existential types", they don't behave the way one would think an existential type behaves because the existential quantifier is scoped over the entire function unlike existential types like dyn trait—note in argument position, the quantifier is tightly scoped (i.e., not scoped over the entire function).

I suggest to re-write the code without type inference or method syntax to understand what's going on. Unfortunately Rust does not (currently) allow you to specify impl trait when defining the type of a variable, so the below is the closest thing you can get:

fn build<C1, C2, C3>(this: A<C1>, input: i32) -> C2::Output
where
    C1: Call<C2, Output = C3>,
    C2: Call<i32, Output = i32>,
    C3: Call<i32, Output = i32>,
{
    drop(this);
    let this: A<ToAddCall> = A(ToAddCall);
    // Substitute `AddCall` with `impl Call<i32, Output = i32>` in your
    // head, but this is the real type it's just "hidden" from calling code.
    let b: B<ToAddCall, AddCall> = A::<ToAddCall>::build_b(this);
    // Substitute `AddCall` with `impl Call<i32, Output = i32>` in your
    // head, but this is the real type it's just "hidden" from calling code.
    // This compiles just fine since the trait bounds are met.
    let c: i32 = B::<ToAddCall, AddCall>::build_last::<AddCall, i32>(b, input);
    c
}

impl<C1> A<C1> {
    pub fn build<C2, C3>(self, input: i32) -> i32
    where
        C1: Call<C2, Output = C3>,
        C2: Call<i32, Output = i32>,
        C3: Call<i32, Output = i32>,
    {
        // Substitute `AddCall` with `impl Call<i32, Output = i32>` in your
        // head, but this is the real type it's just "hidden" from calling code.
        let b: B<C1, AddCall> = A::<C1>::build_b(self);
        // Substitute `AddCall` with `impl Call<i32, Output = i32>` in your
        // head, but this is the real type it's just "hidden" from calling code.
        // This won't compile since we can only call `B::build_last`
        // with `C2` per the trait bound.
        let c: i32 = B::<C1, AddCall>::build_last::<AddCall, i32>(b, input);
        c
    }
    /// Substitute `AddCall` with `impl Call<i32, Output = i32>` in your
    /// head, but this is the real type it's just "hidden" from calling code.
    pub fn build_b(self) -> B<C1, AddCall> {
        B(self.0, AddCall(1))
    }
}

My last possible explanation that is less technical is to think of impl trait as between a "normal" type and a "true" existential type like dyn trait. In particular it shares the opaque property of dyn trait; but otherwise behaves like a "normal" type.

In argument position, impl trait is opaque to the function body but not to calling code—with type inference this can be hard to see sometimes—as a result, it behaves eerily similar to a polymorphic function where an explicit type parameter is defined: as the library author you only know what the type can do (via the trait bounds) but not what the type is.

In return position, impl trait is opaque to calling code (i.e., I don't know what the type is only what it can do when I receive an instance of it from the function).

The rest of it should be thought of as a normal type. This is why when returning an impl trait from a function it must always be the same type unlike dyn trait where you can return any type you want.

2 Likes

Thank you for your excellent explanation! I now understand why my code doesn't work.

However, "requiring generic parameters that can be called with internally constructed values" should be a common requirement.

For example, with tower::ServiceBuilder, if I want users to provide some Services that I'll combine with my own Services, I will encounter this problem: if a user provides an L: Layer<impl Service<Message, ()>, Service = OtherService> , and I internally provide additional Services (as a Sink Service<Message, Response = ()>) through ServiceBuilder , then I need to combine these together in my own builder. I want to hide this process from the user (meaning, from the user's perspective, after calling my own builder's build(), the final returned type should be something like Chain<Service1, Chain<Service2, ..., Chain<ServiceN, impl MySink>...> ). I have meet these kind of requirements for several times before.

According to your description, it seems only HKTs (Higher-Kinded Types) can achieve this purpose, but that seems too powerful to sound. Is there any workaround method available now? Or am I forced to split this process into two steps and require users to assemble the Service chain through two fixed function calls?

We may have HRTBs over types some day (but no time soon).

Type erasure: use Box<dyn Trait<..>> or similar, instead of for<T: Trait<..>>. This is probably the closest in spirit.

Expose your return types: use something nameable instead of -> impl Trait.

TAIT will allow -> impl Trait without exposing the return type, though this still leaks implementation details about A::build. Still unstable though.

Probably various forms of refactoring your traits.

Which are possible depends on your use case.

2 Likes

Thanks for the detailed code! I'll waiting for TAIT stabilization (and trying using nightly for experiment for now).

The lang-team weekly meeting looks interesting, and I will keep eyes on this for when the TAIT could stabilization :melting_face:.