Jackson: Serialize Map with non-String key (in fact with any Serializable key) and abstract classes

I recently stumbled over the otherwise really great Jackson JSON parser’s inability to (de)serialize a Map that has a non-String key out of the box. The explanation is rather self-evident. JSON is a String-based format and thus any key for a Map has to be a String. So if you don’t specify a custom serializer/deserializer like here, Jackson just calls toString() on your key on serialization. Consider the following simple example:

class Container
{
    @JsonProperty( "map" )
    Map<Key, String> map = new HashMap<Key, String>();
}

class Key
{
    String compositeIdString;
    double compositeIdDouble;
}

Container container = new Container();
Key key = new Key();
key.compositeIdString = "test";
key.compositeIdDouble = 100;                   
container.map.put( key, "test1" );
// serialize json object from container with Map
ObjectMapper mapper = new ObjectMapper();
String outputJson = mapper.writeValueAsString( container );
System.out.println(outputJson);
Container deserializedContainer =
    mapper.readValue( outputJson, Container.class );
assertEquals( container, deserializedContainer );

The result is rather sad:

{“map”:{“Key@8a353ed”:”test1″}}

But it makes sense. Imagine what happens upon deserialization: your key class needs to be re-created from the output of toString(). So adding a proper toString() method and implementing a deserialzier like from this post solves the issue here:

class KeyKeyDeserializer extends KeyDeserializer
{
    @Override
    public Object deserializeKey( String key,
        DeserializationContext ctxt )
        throws IOException, JsonProcessingException
    {
        Key newKey = new Key();
        newKey.compositeIdString = parseString( key );
        newKey.compositeIdString = parseDouble( key );
        return newKey;
    }

    double parseDouble(String key) { ... }

    String parseString(String key) { ... }
}

// annotate our map with the new deserializer
class Container
{
    @JsonDeserialize( keyUsing = KeyKeyDeserializer.class )
    Map<Key, String> map = new HashMap<Key, String>();
}

Still, this is kind of awkward. You need to parse your Key class from the String. And there is one far more important use case here: Using abstract classes as keys. Although Jackson can deal with abstract classes just fine, it cannot properly deserialize the key on its own. Lets consider another example:

class ContainerAbstract
{
    @JsonDeserialize( keyUsing = KeyKeyDeserializer.class )
    Map<AbstractKey, String> map =
        new HashMap<AbstractKey, String>();
}

abstract class AbstractKey
{ 
}
    
class Key1 extends AbstractKey
{ 
    String compositeIdString;    
}
class Key2 extends AbstractKey
{
    double compositeIdDouble;
}

Lets just assume that in real life AbstractKey would make a little more sense than in the above example and you cannot infer the actual type of the abstract class at runtime by specifying a list of possible types or some attribute guessing logic, this is what happened to me. The Key class was an abstract type that was meant to be overridden by users of the framework, and there were already hundreds of concrete Key classes cluttered across a huge code base.
So the problem now is that you can no longer instantiate your abstract class in the deserializer. You don’t know the actual type and you cannot guess it either. And the type information coming in is just a String.

Then it struck me: Jackson was successfully serializing and deserializing the AbstractKey class at another position in the code, where it was just referenced by itself, not contained in a map. It just converted it to JSON, adding enough information to re-create it using reflection at runtime. So  why not using the JSON String (sic!) as the key value? Then we basically have JSON within JSON, and using Jackson to de-serialize our key. Here is what it looks like in code:

class SerializableKeySerializer
    extends JsonSerializer<AbstractKey>
{
    static ObjectMapper mapper = new ObjectMapper();

    @Override
    public void serialize( AbstractKey value,
        JsonGenerator jgen,
        SerializerProvider provider )
        throws IOException, JsonProcessingException
    {
        String json = mapper.writeValueAsString( value );
        jgen.writeFieldName( json );
    }
}

class SerializableKeyDeserializer extends KeyDeserializer
{
    static ObjectMapper mapper = new ObjectMapper();

    @Override
    public Object deserializeKey( String key,
        DeserializationContext ctxt )
        throws IOException, JsonProcessingException
    {
        return mapper.readValue( key, AbstractKey.class );
    }
}

@JsonTypeInfo(use=Id.CLASS,
    include = JsonTypeInfo.As.PROPERTY, property = "c")
class Container
{       
    @JsonSerialize( keyUsing =
         SerializableKeySerializer.class )
    @JsonDeserialize( keyUsing =
         SerializableKeyDeserializer .class ) 
    Map<AbstractKey, String> map =
         new HashMap<AbstractKey, String>();
}

Container container = new Container();
Key1 key1 = new Key1();
key1.compositeIdString = "test";
Key2 key2 = new Key2();
key2.compositeIdDouble = 100;      
container.map.put( key1, "test1" );
container.map.put( key2, "test2" );

// serialize
ObjectMapper mapper = new ObjectMapper();
String outputJson = mapper.writeValueAsString( container );
System.out.println(outputJson);

// de-serialize container from stream
Container deserializedContainer =
    mapper.readValue( outputJson, Container.class );
assertEquals( container, deserializedContainer );

And this is the JSON output when combined with our two Key1 and Key2 classes and the test code:

{
    "map": {
        "{\"c\":\".Key1\",\"compositeIdString\":\"test\"}":
            "test1",
        "{\"c\":\".Key2\",\"compositeIdDouble\":100}":
            "test2"
    }
}

So we added two annotations for serializer and deserializer for Container, there is also a @JsonTypeInfo annotation at class level. This will create a new attribute ‘c’ which will hold the full qualified class name. You can use any of the other runtime class-resolve methods here. Both the key classes can be deserialized using the json that is in there — technically it is just a string for Jackson. It is escaped so it won’t be interpreted as an additional JSON node. One could start optimizing here, using some kind of compression and/or feed it to a Base64 encoder. This will make it look more like a real key-value pair. But technically, the JSON is sufficient as a key.

2 thoughts on “Jackson: Serialize Map with non-String key (in fact with any Serializable key) and abstract classes”

  1. Is there any way to do @JsonTypeInfo(use=Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = “c”)
    on the Container class, but without putting it directly in the class Container. I tried to use mixin but it didn’t work. Is there another way?

Leave a Reply

Your email address will not be published. Required fields are marked *