jackson-databind icon indicating copy to clipboard operation
jackson-databind copied to clipboard

Generics using wildcard not correctly resolved

Open wimdeblauwe opened this issue 3 months ago • 4 comments

Search before asking

  • [x] I searched in the issues and found nothing similar.

Describe the bug

I opened a Spring Boot issue, but it seems the issue is Jackson related. See https://github.com/spring-projects/spring-boot/issues/46994#issuecomment-3236302850 for the full details and a reproducer.

Version Information

2.19.2

Reproduction

package com.example.jsontesterbug;

import static org.assertj.core.api.Assertions.assertThat;

import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.TypeVariable;
import java.util.Arrays;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;

class MessageWrapperTest {
	
  private MessageWrapper<?> wildcardWrapper;
  
  private MessageWrapper<EmailSettings> specificWrapper;

  private ObjectMapper objectMapper = new ObjectMapper();
  
  @Test
  void wildcardWrapper() throws NoSuchFieldException, SecurityException, JsonProcessingException {
	  serializeWithTypeFromField("wildcardWrapper");
  }
  
  @Test
  void specificWrapper() throws NoSuchFieldException, SecurityException, JsonProcessingException {
	  serializeWithTypeFromField("specificWrapper");
  }
  
  private void serializeWithTypeFromField(String field) throws NoSuchFieldException, SecurityException, JsonProcessingException {
	  MessageWrapper<EmailSettings> wrapper = new MessageWrapper<>(new EmailSettings("[email protected]"),
			  "Sample Message");
	  Type genericType = MessageWrapperTest.class.getDeclaredField(field).getGenericType();
	  TypeVariable<?>[] typeParameters = ((Class<?>)((ParameterizedType)genericType).getRawType()).getTypeParameters();
	  Type[] bounds = typeParameters[0].getBounds();
	  System.out.println(field);
	  System.out.println("  Generic type: " + genericType);
	  System.out.println("    Bounds: " + Arrays.toString(bounds));
	  JavaType jacksonType = this.objectMapper.constructType(genericType);
	  System.out.println("  Jackson type: " + jacksonType);
	  String json = this.objectMapper.writerFor(jacksonType).writeValueAsString(wrapper);
	  System.out.println("  JSON: " + json);
	  assertThat(json).contains("\"type\":\"EMAIL\"");
	  assertThat(json).contains("\"email\":\"[email protected]\"");
  }

}

Running this gives this output:

wildcardWrapper
  Generic type: com.example.jsontesterbug.MessageWrapper<?>
    Bounds: [interface com.example.jsontesterbug.Settings]
  Jackson type: [simple type, class com.example.jsontesterbug.MessageWrapper<java.lang.Object>]
  JSON: {"settings":{"email":"[email protected]"},"message":"Sample Message"}
specificWrapper
  Generic type: com.example.jsontesterbug.MessageWrapper<com.example.jsontesterbug.EmailSettings>
    Bounds: [interface com.example.jsontesterbug.Settings]
  Jackson type: [simple type, class com.example.jsontesterbug.MessageWrapper<com.example.jsontesterbug.EmailSettings>]
  JSON: {"settings":{"type":"EMAIL","email":"[email protected]"},"message":"Sample Message"}

Expected behavior

The JSON output should be the same in both cases.

Additional context

No response

wimdeblauwe avatar Aug 29 '25 09:08 wimdeblauwe

Can you provide the definition of the MessageWrapper class?

pjfanning avatar Aug 29 '25 10:08 pjfanning

You can view it in the zip file of the Spring Boot issue as well, but here it is:

public record MessageWrapper<T extends Settings>(T settings, String message) {
}

This is the Settings interface:

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeInfo.Id;

@JsonTypeInfo(use = Id.NAME, property = "type")
@JsonSubTypes({
    @JsonSubTypes.Type(
        value = EmailSettings.class,
        name = EmailSettings.TYPE),
    @JsonSubTypes.Type(
        value = PhoneSettings.class,
        name = PhoneSettings.TYPE),
})
public interface Settings {

}

With EmailSettings:

public record EmailSettings(String email) implements Settings {

  public static final String TYPE = "EMAIL";
}

and PhoneSettings:

public record PhoneSettings(String phoneNumber) implements Settings {

  public static final String TYPE = "PHONE";
}

wimdeblauwe avatar Aug 29 '25 15:08 wimdeblauwe

Probably makes no difference, but there is now 2.20.0 release to try out.

cowtowncoder avatar Sep 06 '25 02:09 cowtowncoder

This:

Jackson type: [simple type, class com.example.jsontesterbug.MessageWrapper<java.lang.Object>]

shows underlying problem: type is resolved as MessageWrapper<?> and that's why @JsonTypeInfo does not take effect. This is from getting

private MessageWrapper<?> wildcardWrapper;

method signature, which for some reason does not handle bounds for MessageWrapper type parameter.

Test could probably be simplified to just that type resolution problem.

@yawkat might be familiar with this problem actually.

cowtowncoder avatar Sep 06 '25 02:09 cowtowncoder