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
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-coreonly.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@RequestBodytarget type, so a controller combining aString-criteria search interface with a create/update interface fails to deserialize a valid JSON body:In Java the two
Ivariables are distinctTypeVariableinstances (differentgetGenericDeclaration()), 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)
Expected:
class java.lang.Long(fromCreate<Long, Long>).Actual:
class java.lang.String(leaked fromSearch<String, Long>).Real-world manifestation (Spring MVC)
POST /itemswith{"name":"foo"}→ 400, body read asString.A controller implementing
WithCreateonly works (Item). Full runnable project + MockMvc test:(https://github.com/antonin-daniau/spring-typevar-collision-repro).Root cause:
resolveTypedisagrees withresolveParameterTypeFor the same
(TypeVariable I, ItemController.class):spring-coreentry pointGenericTypeResolver.resolveParameterType(MethodParameter, Class)(keeps method/declaring-interface context)Item✅GenericTypeResolver.resolveType(Type, Class)(bareTypeVariable+ controller)String❌AbstractJackson2HttpMessageConverter#getJavaType(delegates toresolveType)String❌getJavaTypein spring-web 6.2.x:resolveType→ privateresolveVariable(TypeVariable, ResolvableType)walks the supertype and all interfaces ofcontextClass, matching the variable by name through theResolvableTypevariable resolver.WithSearch<String, …>(reached viaWithSimpleTextSearch) declares a parameter also namedI, matches first, and wins. The variable's declaring type (WithCreate) is not taken into account.Relation to existing issues (searched, distinct)
resolveParameterTypeis already correct; onlyresolveType(Type, Class)is wrong.ResolvableTypetype variable resolution #33887 (RefineResolvableTypetype variable resolution, open) looks like the umbrella this belongs to.Suggested direction
resolveType(Type, Class)ignores theTypeVariable's declaring context. Keying resolution on the variable's identity (getGenericDeclaration()+ name) rather than the name alone would only matchCreate'sIagainstCreate's binding — the behaviourresolveParameterTypealready exhibits. Alternatively,getJavaTypecould preferMethodParameter-aware resolution when aMethodParameteris 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-coreproof + layered diagnostic + MVC MockMvc test):https://github.com/antonin-daniau/spring-typevar-collision-repro