1. Introduction

Kotlin sealed classes are a powerful feature that allows developers to represent restricted class hierarchies, which can also be used in conjunction with when expressions for exhaustive pattern matching. Sometimes, we need to represent sealed classes over the wire, and so we’ll need to understand how to serialize and deserialize sealed classes to achieve this.

In this tutorial, we’ll explore various approaches to tackle this challenge in Kotlin.

2. Understanding Sealed Classes

Sealed classes are used to represent a restricted class hierarchy. This means that a sealed class can have a specific set of subclasses, and no other subclasses outside this set. All subclasses of a sealed class must be declared within the sealed class.

For instance, let’s define a sealed class with two subclasses:

sealed class Animal {
    abstract val name: String
    data class Dog(override val name: String, val breed: String) : Animal()
    data class Cat(override val name: String, val lives: Int) : Animal()
}

In this example, Animal is the sealed class with two possible subclasses: Dog and Cat. This hierarchy ensures that any instance of Animal must be either a Dog or a Cat.

3. Using kotlinx.serialization

To begin with, kotlinx.serialization is a Kotlin-native library designed for serializing and deserializing objects, including sealed classes. This library integrates seamlessly with the Kotlin ecosystem.

To be able to use our sealed class with kotlinx.serialization, we’ll need to mark all three classes with @Serializable. This annotation informs the library that it needs to generate the necessary code to transform the class to and from serializable formats like JSON:

@Serializable
sealed class Animal {
    abstract val name: String
    @Serializable
    data class Dog(override val name: String, val breed: String) : Animal()
    @Serializable
    data class Cat(override val name: String, val color: String) : Animal()
}

Annotating a class with @Serializable when using kotlinx.serialization enables the class to participate in the serialization and deserialization process. Additionally, it supports polymorphism by appropriately handling different subclasses of a sealed class, ensuring type safety and consistency without relying on reflection.

Specifically, the @Serializable annotation also instructs the compiler to generate a serializer() extension function on the companion class of each annotated class.

3.1. Serialization

Next, using our annotated sealed Animal class, let’s serialize instances of the subclasses using the kotlinx.serialization library:

@Test
fun `serialize sealed class using kotlinx serialization library`() {
    val dog = Animal.Dog("Buddy", "Labrador")
    val serializedDog = Json.encodeToString(dog)

    val cat = Animal.Cat("Mike", "Black")
    val serializedCat = Json.encodeToString(cat)

    assertEquals("{\"name\":\"Buddy\",\"breed\":\"Labrador\"}", serializedDog)
    assertEquals("{\"name\":\"Mike\",\"color\":\"Black\"}", serializedCat)
}

This test verifies the serialization of the Animal.Dog and Animal.Cat subclasses using Json.encodeToString(). It creates instances of these subclasses and serializes them to JSON strings. Finally, it asserts that the output matches the expected JSON.

Moreover, we call the encodeToString() method without the generated serializer() because we’re directly serializing specific subclasses. The @Serializable annotation on these subclasses also ensures that their serializers are automatically used.

3.2. Deserialization

Additionally, let’s see how we can deserialize a JSON string into a subclass of Animal:

@Test
fun `deserialize sealed class using kotlinx serialization library`() {
    val json = Json {
        serializersModule = SerializersModule {
            polymorphic(Animal::class) {
                subclass(Animal.Dog::class, Animal.Dog.serializer())
                subclass(Animal.Cat::class, Animal.Cat.serializer())
            }
        }
    }
    val dog = Animal.Dog("Buddy", "Labrador")
    val serializedDog = json.encodeToString(Animal.serializer(), dog)
    val deserializedDog = json.decodeFromString<Animal>(serializedDog)

    assertEquals(dog, deserializedDog)

    val cat = Animal.Cat("Mike", "Black")
    val serializedCat = json.encodeToString(Animal.serializer(), cat)
    val deserializedCat = json.decodeFromString<Animal>(serializedCat)

    assertEquals(cat, deserializedCat)
}

The json variable configures kotlinx.serialization’s Json object for deserializing a sealed class hierarchy. It defines polymorphic serialization using a SerializersModule. Specifically, we register the Animal.Dog and Animal.Cat subclasses with their respective serializers.

However, we need to use the generated serializer() function from the base sealed class Animal when calling encodeToString() because we’re dealing with polymorphic deserialization. The Animal serializer handles the polymorphism to deserialize the correct subclass.

This is essential because the library needs to know which subclass to instantiate during deserialization. By registering subclasses with their serializers, the library can serialize objects with type information and correctly deserialize JSON strings into the right subclasses. This ensures that properties and types are accurately mapped.

This is also why we need to annotate the base class and the two concrete classes with the @Serializable annotation since we will use the serializer() features of all three classes.

4. Using Moshi

Alternatively, Moshi is a popular JSON library for Kotlin that can handle sealed classes with some custom setup.

First, let’s construct a Moshi serializer:

val moshi: Moshi = Moshi.Builder()
        .add(
            PolymorphicJsonAdapterFactory.of(Animal::class.java, "type")
                .withSubtype(Animal.Dog::class.java, "dog")
                .withSubtype(Animal.Cat::class.java, "cat")
        )
        .add(KotlinJsonAdapterFactory())
        .build()

We begin here by configuring Moshi for JSON serialization by adding KotlinJsonAdapterFactory, which provides support for Kotlin. Additionally, it incorporates the PolymorphicJsonAdapterFactory to handle polymorphic types within the Animal sealed class hierarchy. Specifically, it designates Dog and Cat as subclasses identified by the type field.

We don’t need annotations on the sealed class or its subclasses to use Moshi.

4.1. Serialization

Now, let’s use Moshi to serialize the Animal sealed class:

@Test
fun `serialize sealed class using moshi library`() {

    val dog = Animal.Dog("Buddy", "Labrador")
    val cat = Animal.Cat("Mike", "Black")

    val jsonAdapter = moshi.adapter(Animal::class.java)

    val serializedDog = jsonAdapter.toJson(dog)
    val serializedCat = jsonAdapter.toJson(cat)

    assertEquals("{\"type\":\"dog\",\"name\":\"Buddy\",\"breed\":\"Labrador\"}", serializedDog)
    assertEquals("{\"type\":\"cat\",\"name\":\"Mike\",\"color\":\"Black\"}", serializedCat)
}

This test verifies the serialization of polymorphic Animal subclasses using Moshi, ensuring that the toJson() method correctly serializes these instances to JSON strings with the expected type information.

The type property is crucial as it distinguishes between the different subclasses during the serialization and deserialization processes. Deserializing a sealed class with Moshi requires that we provide a way to distinguish the subclasses of the sealed class. If the JSON doesn’t have the type field, Moshi can’t determine which subclass of Animal to instantiate, resulting in an error during deserialization.

Furthermore, both the PolymorphicJsonAdapterFactory and KotlinJsonAdapterFactory are necessary. The PolymorphicJsonAdapterFactory manages the polymorphic serialization and deserialization by distinguishing between subclasses based on the type field. Meanwhile, the KotlinJsonAdapterFactory is essential for supporting Kotlin-specific features such as data classes and default parameter values. Without the KotlinJsonAdapterFactory, Moshi cannot properly handle the reflective serialization of Kotlin classes, resulting in errors.

4.2. Deserialization

Finally, let’s look at how we can also deserialize the Animal sealed class using Moshi:

@Test
fun `deserialize sealed class using moshi library`() {
    val jsonAdapter = moshi.adapter(Animal::class.java)

    val serializedDog = """{"type":"dog","name":"Buddy","breed":"Labrador"}"""
    val deserializedDog = jsonAdapter.fromJson(serializedDog) as Animal.Dog

    val serializedCat = """{"type":"cat","name":"Mike","color":"Black"}"""
    val deserializedCat = jsonAdapter.fromJson(serializedCat) as Animal.Cat

    assertEquals(Animal.Dog("Buddy", "Labrador"), deserializedDog)
    assertEquals(Animal.Cat("Mike", "Black"), deserializedCat)
}

This test utilizes the fromJson() method to convert JSON strings into their respective Dog and Cat subclasses of the Animal sealed class. Lastly, we verify that the resulting objects accurately reflect the expected instances.

5. Conclusion

In this article, we’ve explored different approaches to serializing and deserializing sealed classes using two popular libraries.

We demonstrated handling serialization and deserialization with kotlinx.serialization, leveraging its seamless integration with the Kotlin ecosystem. We also highlighted the importance of the @Serializable annotation and how it enables the library to generate code necessary for transforming classes to and from JSON formats.

Then, we configured Moshi to manage polymorphic types within sealed classes, ensuring proper JSON serialization and deserialization. We also discussed how the PolymorphicJsonAdapterFactory plays a crucial role in identifying and using the correct serializer for each subclass.

By understanding and applying these techniques, we can effectively serialize and deserialize complex class hierarchies, enhancing the robustness and flexibility of our Kotlin applications.

As usual, all the examples are available over on GitHub.


» 下一篇: Kotlin与Spring教程