Skip to content

GenericTypeResolver.resolveType picks a sibling interface's binding when two interfaces share a type-variable name (breaks @RequestBody deserialization) #36890

@antonin-daniau

Description

@antonin-daniau

Affects: 6.2.12 (also expected on 6.2.x main). Reproduced via Spring Boot 3.5.7, Jackson 2.19.2, Java 25 — but the core reproducer below depends on spring-core only.

Description

When a class implements two unrelated generic interfaces whose type variables happen to share the same name (e.g. I), bound to different concrete types, GenericTypeResolver.resolveType(Type, Class) resolves a type variable declared by one interface to the other interface's binding.

AbstractJackson2HttpMessageConverter#getJavaType(Type, Class) calls exactly this method to choose the @RequestBody target type, so a controller combining a String-criteria search interface with a create/update interface fails to deserialize a valid JSON body:

HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type
`java.lang.String` from Object value (token `JsonToken.START_OBJECT`)

In Java the two I variables are distinct TypeVariable instances (different getGenericDeclaration()), so there is no real ambiguity — the resolution is driven by the variable name.

Minimal reproducer (spring-core only — no MVC, no Jackson, no Boot)

import org.springframework.core.GenericTypeResolver;
import java.lang.reflect.Type;

public class Demo {
    interface Search<I, O> {}                                      // I = search criteria
    interface Create<I, O> { default O create(I body) { return null; } } // I = @RequestBody

    static class Controller implements Search<String, Long>, Create<Long, Long> {} // String vs Long

    public static void main(String[] args) throws Exception {
        Type createBody = Create.class.getMethod("create", Object.class)
                                      .getGenericParameterTypes()[0];   // TypeVariable "I" of Create
        Type resolved = GenericTypeResolver.resolveType(createBody, Controller.class);
        System.out.println(resolved);
    }
}

Expected: class java.lang.Long (from Create<Long, Long>).
Actual: class java.lang.String (leaked from Search<String, Long>).

Real-world manifestation (Spring MVC)

interface WithSearch<I, O>        { @PostMapping("/search") default List<O> search(@RequestBody I criteria) {...} }
interface WithSimpleTextSearch<O> extends WithSearch<String, O> {}     // pins I = String
interface WithCreate<I, O>        { @PostMapping            default O create(@RequestBody I body) {...} }

@RestController @RequestMapping("/items")
class ItemController implements WithSimpleTextSearch<Item>, WithCreate<Item, Item> {}

POST /items with {"name":"foo"}400, body read as String.
A controller implementing WithCreate only works (Item). Full runnable project + MockMvc test: (https://github.com/antonin-daniau/spring-typevar-collision-repro).

Root cause: resolveType disagrees with resolveParameterType

For the same (TypeVariable I, ItemController.class):

spring-core entry point resolves to
GenericTypeResolver.resolveParameterType(MethodParameter, Class) (keeps method/declaring-interface context) Item
GenericTypeResolver.resolveType(Type, Class) (bare TypeVariable + controller) String
AbstractJackson2HttpMessageConverter#getJavaType (delegates to resolveType) String

getJavaType in spring-web 6.2.x:

protected JavaType getJavaType(Type type, @Nullable Class<?> contextClass) {
    return this.defaultObjectMapper.constructType(
            GenericTypeResolver.resolveType(type, contextClass));
}

resolveType → private resolveVariable(TypeVariable, ResolvableType) walks the supertype and all interfaces of contextClass, matching the variable by name through the ResolvableType variable resolver. WithSearch<String, …> (reached via WithSimpleTextSearch) declares a parameter also named I, matches first, and wins. The variable's declaring type (WithCreate) is not taken into account.

Relation to existing issues (searched, distinct)

Suggested direction

resolveType(Type, Class) ignores the TypeVariable's declaring context. Keying resolution on the variable's identity (getGenericDeclaration() + name) rather than the name alone would only match Create's I against Create's binding — the behaviour resolveParameterType already exhibits. Alternatively, getJavaType could prefer MethodParameter-aware resolution when a MethodParameter is available.

Workaround

Give each @RequestBody-bearing type variable a unique name (CREATE_INPUT, UPDATE_INPUT, CRITERIA, …) so no two interfaces in a controller's hierarchy share a type-variable name. This fully removes the symptom and confirms the resolution is name-based.

Environment

  • Spring Framework 6.2.12 / Spring Boot 3.5.7
  • Jackson 2.19.2, Java 25, Maven 3.9.x
  • Reproducer repository (minimal spring-core proof + layered diagnostic + MVC MockMvc test): https://github.com/antonin-daniau/spring-typevar-collision-repro

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions