How to serialize/deserialize Kotlin sealed class?
Asked Answered
B

5

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?

Buffington answered 3/5, 2018 at 14:26 Comment(2)
Why are you trying to deserialise a singleton?Function
@Function - 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.Buffington
B
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.

Buffington answered 24/5, 2018 at 17:27 Comment(0)
A
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)
Amalburga answered 3/5, 2018 at 14:59 Comment(2)
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 :)Buffington
@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.Amalburga
A
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.

Anus answered 29/1, 2019 at 14:42 Comment(4)
This is the best solution at the moment. But I guess it doesn't handled nested sealed classes?Caplin
@CarsonHolzheimer I haven't tried it by I don't see why it wouldn't handle nested sealed classes just as well.Anus
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?Jarred
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 somethingAnus
B
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.

Buffington answered 24/5, 2018 at 17:27 Comment(0)
C
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

Chaim answered 1/3, 2022 at 23:31 Comment(1)
you can also use JsonTypeInfo.Id.DEDUCTION for a more natural deserialization. you will need to make sure that the sealed type is deducibleCategorize
A
0

I faced a similar issue and spent a day to fix it, and one of the answer by @X.Y https://mcmap.net/q/680158/-how-to-serialize-deserialize-kotlin-sealed-class 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://mcmap.net/q/680158/-how-to-serialize-deserialize-kotlin-sealed-class 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

Ameba answered 20/10, 2023 at 5:52 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.