Affects: 6.2.0-SNAPSHOT

Consider the following code (enhanced with Lombok for conciseness):

public abstract class Parent<T> {
    @RequiredArgsConstructor
    @Getter
    public final class Child {
        private final T value;
    }
}

@Component
public class ParentInt extends Parent<Integer> {
    @Bean
    public Child childInt() {
        return new Child(123);
    }
}

@Component
public class ParentString extends Parent<String> {
    @Bean
    public Child childString() {
        return new Child("abc");
    }
}

@Component
@RequiredArgsConstructor
public class Tester {
    private final ParentString.Child childInt; // <- pay attention to the name

    @PostConstruct
    public void test() {
        childInt.getValue().startsWith("");
        // java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String
    }
}

Running it will cause a java.lang.ClassCastException.

In fact, the types ParentInt.Child and ParentString.Child are not the same and the compiler will reject any such subtyping attempt. Spring on the other hand sees the two children beans as being of the same type (even though they aren't); the "disambiguation" is solved using the bean name, here childInt.

This may be an already documented implementation limitation, but I did not find anything on the matter.

Comment From: quaff

Have you tried with latest snapshot version 6.2.0-SNAPSHOT? There are lots of commits related to core container recently.

Comment From: FlorianCassayre

I'll try that, thanks.

Comment From: FlorianCassayre

Same problem with 6.2.0-SNAPSHOT.

Comment From: jhoeller

From an initial analysis, this won't work in 6.2 either yet since the compiler does not retain the generic type in the class at all in such a scenario. The only place where the generic type is present is the enclosing class, and that is only referenced in the this$0 field of the Child instance; the Child class itself is not subclassed with complete type information.

Since our generic type algorithm works against types, we simply don't see such information from an enclosing instance. We'd literally have to match against the nested instance rather than the type, introspecting the given instance type first and then grabbing the this$0 field from the instance and introspecting the type of that enclosing instance.

The container needs to infer type matches from static signatures before bean instantiation, so it does not look like there is a proper solution for this in the context of Spring's core container. You might have to resort to qualifiers or other indicators for the actual target bean (such as its bean name as an implicit qualifier) rather than rely on type matching.

Comment From: sbrannen

enhanced with Lombok for conciseness

Although we appreciate your desire to be concise, please refrain from supplying examples that rely on Lombok, since that makes it more difficult for us to use your example.

Comment From: sbrannen

We'd literally have to match against the nested instance rather than the type, introspecting the given instance type first and then grabbing the this$0 field from the instance and introspecting the type of that enclosing instance.

Furthermore, since Java 18 one can no longer rely on the presence of a this$0 field that holds a reference to the enclosing instance.

See:

Comment From: sbrannen

The container needs to infer type matches from static signatures before bean instantiation, so it does not look like there is a proper solution for this in the context of Spring's core container.

Yes, that's the crux of the issue.

You might have to resort to qualifiers or other indicators for the actual target bean (such as its bean name as an implicit qualifier) rather than rely on type matching.

Along those lines, I've prepared an all-in-one test class that reproduces the issue (without using Lombok) and allows for easy modification to try out various approaches.

@SpringJUnitConfig
class OriginalIntegrationTests {

    private final ParentString.Child child;

    @Autowired
    OriginalIntegrationTests(Parent<String>.Child childInt) {
        this.child = childInt;
    }

    @Test
    void test() {
        // java.lang.ClassCastException: class java.lang.Integer cannot be cast
        // to class java.lang.String
        assertThat(this.child.getValue()).startsWith("a");
    }

    @Configuration
    @Import({ ParentString.class, ParentInt.class })
    static class Config {
    }

    static abstract class Parent<T> {

        public class Child {

            public Child(T value) {
                this.value = value;
            }

            private final T value;

            public T getValue() {
                return this.value;
            }
        }
    }

    static class ParentInt extends Parent<Integer> {
        @Bean
        public Child childInt() {
            return new Child(123);
        }
    }

    static class ParentString extends Parent<String> {
        @Bean
        public Child childString() {
            return new Child("abc");
        }
    }

}

Unmodified, the test will fail with the originally reported ClassCastException.

Changing the parameter name in the constructor from childInt to child results in:

NoUniqueBeanDefinitionException: No qualifying bean of type 'example.OriginalIntegrationTests$Parent$Child' available: expected single matching bean but found 2: childString,childInt

And that's the expected behavior. By originally specifying childInt, you explicitly instructed Spring to pick the wrong one.

Changing the parameter name to childString instructs Spring to pick the correct one, allowing the test to pass.

And as Juergen mentioned, you could explicitly make use of qualifiers as in the following, which also allows the test to pass.

    OriginalIntegrationTests(@Qualifier("childString") Parent<String>.Child child) {
        this.child = child;
    }

Comment From: sbrannen

the compiler does not retain the generic type in the class at all in such a scenario. ... Since our generic type algorithm works against types, we simply don't see such information from an enclosing instance.

One way to work around that is to ensure that the instantiation of a Child retains the generic type information.

You can do that be reworking your classes as follows.

static abstract class Parent<T> {

    public class Child<E extends T> {

        public Child(E value) {
            this.value = value;
        }

        private final E value;

        public E getValue() {
            return this.value;
        }
    }
}

static class ParentInt extends Parent<Integer> {
    @Bean
    public Child<Integer> childInt() {
        return new Child<Integer>(123);
    }
}

static class ParentString extends Parent<String> {
    @Bean
    public Child<String> childString() {
        return new Child<String>("abc");
    }
}

With those changes, the following test class passes without the need for any kind of qualifier, relying solely on the generic type information retained at runtime.

@SpringJUnitConfig
class WorkingIntegrationTests {

    private final ParentString.Child<String> child;

    @Autowired
    WorkingIntegrationTests(Parent<String>.Child<String> child) {
        this.child = child;
    }

    @Test
    void test() {
        assertThat(this.child.getValue()).startsWith("a");
    }

    @Configuration
    @Import({ ParentString.class, ParentInt.class })
    static class Config {
    }
}

Comment From: jhoeller

Thanks for the detailed analysis, @sbrannen.

So unfortunately, there is nothing reliable that we can do about this at runtime. Only the source-level Java compiler is able to infer those generics reliably. For runtime differentiation, consistent qualifier hints are needed in addition to the type declaration.

Comment From: FlorianCassayre

Thanks for the very insightful analysis, much appreciated.

The proposed fix is unfortunate, though I understand that there isn't much more we could do since the type information is simply not available to begin with.