18

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?

Zbigniew Malinowski
  • 1,034
  • 1
  • 9
  • 22
  • Why are you trying to deserialise a singleton? – jrtapsell May 03 '18 at 14:52
  • 2
    @jrtapsell - as it doesn't hold any data, there's no need to have more than one instance of this class. Making it a regular class is a workaround, but it would require equals/hashcode overrides and in general doesn't feel right. – Zbigniew Malinowski May 04 '18 at 09:46

4 Answers4

8

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)
gil.fernandes
  • 12,978
  • 5
  • 63
  • 76
  • 1
    Thanks for your answer. I know that custom serializer can solve this issue (I even think it can be a universal converter, not bound to a specific `object` type). However, I wonder if there's an out-of-the-box solution :) – Zbigniew Malinowski May 04 '18 at 09:48
  • @ZbigniewMalinowski I have tried to find an out-of-the-box solution with Jackson, but it did not make it - possible there is a more clever library (or coder) out there. – gil.fernandes May 04 '18 at 09:53
5

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.

SergioLeone
  • 734
  • 1
  • 10
  • 24
  • This is the best solution at the moment. But I guess it doesn't handled nested sealed classes? – Carson Holzheimer Jul 08 '19 at 15:06
  • @CarsonHolzheimer I haven't tried it by I don't see why it wouldn't handle nested sealed classes just as well. – SergioLeone Jul 09 '19 at 08:01
  • 2
    I must be missing something fundamental here. I can serialize the sealed class instance using this approach, but I'm having trouble deserializing the string using the jackson object mapper: "cannot deserialize from Object value (no delegate- or property-based Creator)". Can you provide an example for deserializing a string into an object instance please? – Bastian Stein May 28 '20 at 11:06
  • I don't think I have it anymore, but I'll try to look it up and will update the answer @BastianStein Maybe you can open another question and post what code you have there. Would be easier to see if you're missing something – SergioLeone Jun 02 '20 at 06:52
3

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.

Zbigniew Malinowski
  • 1,034
  • 1
  • 9
  • 22
3

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

X.Y.
  • 13,726
  • 10
  • 50
  • 63
  • 1
    you can also use `JsonTypeInfo.Id.DEDUCTION` for a more natural deserialization. you will need to make sure that the sealed type is deducible – Asad-ullah Khan Feb 21 '23 at 19:48