How to serialize/deserialize Kotlin sealed class?

28.5k views Asked by At

I have a following sealed class:

sealed class ViewModel {

  data class Loaded(val value : String) : ViewModel()
  object Loading : ViewModel()

}

How can I serialize/deserialize instances of the ViewModel class, let's say to/from JSON format?

I've tried to use Genson serializer/deserializer library - it can handle Kotlin data classes, it's also possible to support polymorphic types (eg. using some metadata to specify concrete types).

However, the library fails on Kotlin object types, as these are singletons without a public constructor. I guess I could write a custom Genson converter to handle it, but maybe there's an easier way to do it?

5

There are 5 answers

0
Zbigniew Malinowski On BEST ANSWER

I ended up implementing a custom Converter plus a Factory to properly plug it into Genson.

It uses Genson's metadata convention to represent the object as:

{ 
  "@class": "com.example.ViewModel.Loading" 
}

The converter assumes useClassMetadata flag set, so serialization just needs to mark an empty object. For deserialization, it resolves class name from metadata, loads it and obtains objectInstance.

object KotlinObjectConverter : Converter<Any> {

override fun serialize(objectData: Any, writer: ObjectWriter, ctx: Context) {
    with(writer) {
        // just empty JSON object, class name will be automatically added as metadata
        beginObject()
        endObject()
    }
}

override fun deserialize(reader: ObjectReader, ctx: Context): Any? =
    Class.forName(reader.nextObjectMetadata().metadata("class"))
        .kotlin.objectInstance
        .also { reader.endObject() }
}

To make sure that this converter is applied only to actual objects, I register it using a factory, that tells Genson when to use it and when to fall back to the default implementation.

object KotlinConverterFactory : Factory<Converter<Any>> {

    override fun create(type: Type, genson: Genson): Converter<Any>? =
        if (TypeUtil.getRawClass(type).kotlin.objectInstance != null) KotlinObjectConverter
        else null

}

The factory can be used to configure Genson via builder:

GensonBuilder()
        .withConverterFactory(KotlinConverterFactory)
        .useClassMetadata(true) // required to add metadata during serialization
        // some other properties
        .create()

The code probably could be even nicer with chained converters feature, but I didn't have time to check it out yet.

2
gil.fernandes On

You are probably right about the creating a custom serializer.

I have tried to serialize and de-serialize your class using the Jackson library and Kotlin.

These are the Maven dependencies for Jackson:

<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-core -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-core</artifactId>
    <version>2.8.8</version>
</dependency>

<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.8.8</version>
</dependency>

You can serialize the sealed class to JSON using this library with no extra custom serializers, but de-serialization requires a custom de-serializer.

Below is the toy code I have used to serialize and de-serialize your sealed class:

import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule

sealed class ViewModel {
    data class Loaded(val value: String) : ViewModel()
    object Loading : ViewModel()
}

// Custom serializer
class ViewModelDeserializer : JsonDeserializer<ViewModel>() {
    override fun deserialize(jp: JsonParser?, p1: DeserializationContext?): ViewModel {
        val node: JsonNode? = jp?.getCodec()?.readTree(jp)
        val value = node?.get("value")
        return if (value != null) ViewModel.Loaded(value.asText()) else ViewModel.Loading
    }
}

fun main(args: Array<String>) {
    val m = createCustomMapper()
    val ser1 = m.writeValueAsString(ViewModel.Loading)
    println(ser1)
    val ser2 = m.writeValueAsString(ViewModel.Loaded("test"))
    println(ser2)
    val deserialized1 = m.readValue(ser1, ViewModel::class.java)
    val deserialized2 = m.readValue(ser2, ViewModel::class.java)
    println(deserialized1)
    println(deserialized2)
}

// Using mapper with custom serializer
private fun createCustomMapper(): ObjectMapper {
    val m = ObjectMapper()
    val sm = SimpleModule()
    sm.addDeserializer(ViewModel::class.java, ViewModelDeserializer())
    m.registerModule(sm)
    return m
}

If you run this code this is the output:

{}
{"value":"test"}
ViewModel$Loading@1753acfe
Loaded(value=test)
4
SergioLeone On

I had a similar problem recently (although using Jackson, not Genson.)

Assuming I have the following:

sealed class Parent(val name: String)

object ChildOne : Parent("ValOne")
object ChildTwo : Parent("ValTwo")

Then adding a JsonCreator function to the sealed class:

sealed class Parent(val name: String) {

    private companion object {
        @JsonCreator
        @JvmStatic
        fun findBySimpleClassName(simpleName: String): Parent? {
            return Parent::class.sealedSubclasses.first {
                it.simpleName == simpleName
            }.objectInstance
        }
    }
}

Now you can deserialize using ChildOne or ChildTwo as key in your json property.

1
X.Y. On

No need for @JsonCreator and sealdSubClass. Jackson has this support in its jackson-module-kotlin, just need one annotation @JsonTypeInfo(use = JsonTypeInfo.Id.NAME):

  @JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
  sealed class SuperClass{
      class A: SuperClass()
      class B: SuperClass()
  }

...
val mapper = jacksonObjectMapper()
val root: SuperClass = mapper.readValue(json)
when(root){
    is A -> "It's A"
    is B -> "It's B"
}

The above example is copied from the its main repo README: https://github.com/FasterXML/jackson-module-kotlin

0
Vinayak On

I faced a similar issue and spent a day to fix it, and one of the answer by @X.Y https://stackoverflow.com/a/71315804/18916677 helped me, but it is kinda incomplete

To solve a problem that you are facing you need two things,

  1. Your jackson objectMapper needs a KotlinModule which is configured to support Singleton objects, a code snippet below

    val objectMapper: ObjectMapper = JsonMapper.builder().build() objectMapper.registerModule(KotlinModule.Builder() .enable(KotlinFeature.SingletonSupport).build())

  2. And the second thing is provided by the above answer -> https://stackoverflow.com/a/71315804/18916677 which is @JsonTypeInfo(use = JsonTypeInfo.Id.NAME) annotation

By doing the above two steps, Jackson serializes the object using the class name and uses the same to deserialize it and keeps the singleton behaviour intact

If you solve your problem with only the second step, you might see that the issue is solved, but you endup having different instances everytime you deserialize