1. Introduction

One of the many powerful features of kotlinx-serialization is polymorphic serialization. Although Kotlin Serialization is static in nature, it’s possible to apply it to more complex situations where we work with dynamic object types at runtime.

As we’ve already covered the basic functionality and directions on how to set up kotlinx-serialization in our project, this tutorial will focus on how we can effectively deal with polymorphic class hierarchies.

2. Closed Polymorphism

We can achieve closed polymorphism, or static polymorphism, using sealed classes. Such a restricted type hierarchy allows us to define a limited set of subclasses. This restriction ensures that the compiler can exhaustively check and handle all possible subtypes and allows for compile-time and type-safe polymorphic serialization.

2.1. Sealed Classes

Using such class hierarchies, we must explicitly annotate all of the subclasses of a sealed class with @Serializable. When we serialize objects of a sealed hierarchy in our code, we must ensure that the compile-time type of the serialized object is a polymorphic one, not a concrete one.

To illustrate this behavior, let’s consider a sealed class Shape and a subclass of it, Circle:

@Serializable
sealed class Shape { abstract val edges: Int }

@Serializable
data class Circle(override val edges: Int, val radius: Double) : Shape()

When we serialize an object of type Circle, its type is serialized as the fully qualified name of the class, which includes the package and class name:

@Test
fun `serializes object with it's type discriminator`() {
    // given
    val circle: Shape = Circle(edges = 1, radius = 2.0)

    // when
    val serializedCircle = Json.encodeToString(circle)
    val deserializedCircle = Json.decodeFromString<Shape>(serializedCircle)

    // then
    assertThat(serializedCircle)
        .isEqualTo(
            """{"type":"com.baeldung.serialization.kotlinx.polymorphism.Circle","edges":1,"radius":2.0}"""
        )
    assertThat(deserializedCircle).isInstanceOf(Circle::class.java)
}

As we’ve learned previously, the Circle object must be declared as a Shape at compile-time for its type discriminator to be serialized. We can, therefore, deserialize it back to a Shape in a type-safe manner. Without this nuance, we could only deserialize it back to a Circle object.

2.2. Type Discriminator Property

Along with the object’s data, we also serialize a type discriminator property. This property is what allows deserialization into the same type of object. By default, the discriminator property’s JSON name is type.

We may change the serialized name of the class value using the @SerialName annotation. We can also change the discriminator JSON property name by configuring the Json module used for serialization:

@Serializable
@SerialName("SerialRectangle")
data class Rectangle(override val edges: Int, val width: Double, val height: Double): Shape()

@Test
fun `uses custom serial name and property for object's type discriminator`() {
    // given
    val jsonConfiguration = Json { classDiscriminator = "#customDiscriminatorProperty" } 
    val rectangle: Shape = Rectangle(edges = 4, width = 4.0, height = 6.0)

    // when
    val serializedRectangle = jsonConfiguration.encodeToString(rectangle)

    // then
    assertThat(serializedCircle)
        .contains(
            """{"#customDiscriminatorProperty":"com.baeldung.serialization.kotlinx.polymorphism.Circle","edges":4,"radius":2.0}"""
        )
}

At the time of writing, it’s also possible to change the serial name using the @JsonClassDiscriminator annotation, however, it’s marked as an unstable experimental API and, therefore, is not recommended for use. If a class already has a property named type, then we must rename the discriminator property while serializing. Failing to do so will result in an IllegalStateException.

3. Open Polymorphism

Serialization can handle arbitrary open or abstract classes. However, since this open polymorphism can lead to subclasses being defined anywhere in the source code, including other modules, we must explicitly register the list of subclasses for serialization at runtime, as it cannot be determined at compile-time.

Let’s have a look at the abstract base class Article, and one derived from it, KotlinLibraryArticle. We’ll look at how to serialize this class hierarchy next:

@Serializable
abstract class Article { abstract val title: String }

@Serializable
@SerialName("KotlinLibraryArticle")
data class KotlinLibraryArticle(override val title: String, val library: String) : Article()

3.1. Abstract Classes and Interfaces

In order to be able to serialize openly polymorphic classes, we must explicitly register their polymorphic hierarchy to our Json format at runtime using the SerializersModule:

val jsonFormat = Json {
    serializersModule = SerializersModule {
        polymorphic(Article::class) {
            subclass(KotlinLibraryArticle::class)
        }
    }
}

We can use this configured Json object to be able to successfully serialize KotlinLibraryArticle objects:

@Test
fun `serializes open polymorphic object`() {
    // given
    val article: Article = KotlinLibraryArticle(
        title = "Class Inheritance with Kotlinx Serialization",
        library = "kotlinx.serialization",
    )

    // when
    val serializedArticle = jsonFormat.encodeToString(article)

    // then
    assertThat(serializedArticle)
        .isEqualTo(
            """{"type":"KotlinLibraryArticle","title":"Class Inheritance with Kotlinx Serialization","library":"kotlinx.serialization"}"""
        )
}

This works for both abstract classes and interfaces, although interfaces are implicitly serializable, so there’s no need to mark them @Serializable.

3.2. Any Type Serialization

We may also require the ability to serialize an object of Any type if we don’t know the exact type at compile-time. However, the Any type is a built-in class, and we can’t annotate it with @Serializable. Therefore, we must register it with the Json format like we’ve done before:

@Test
fun `serializes Any type`() {
    // given
    val jsonFormat = Json {
        serializersModule = SerializersModule {
            polymorphic(Any::class) {
                subclass(KotlinLibraryArticle::class)
            }
        }
    }

    val article: Any = KotlinLibraryArticle(
        title = "Class Inheritance with Kotlinx Serialization",
        library = "kotlinx.serialization",
    )

    // when
    val serializedArticle = jsonFormat.encodeToString(PolymorphicSerializer(Any::class), article)

    // then
    assertThat(serializedArticle)
        .isEqualTo(
            """{"type":"KotlinLibraryArticle","title":"Class Inheritance with Kotlinx Serialization","library":"kotlinx.serialization"}"""
        )
}

We must also pass a PolymorphicSerializer for Any class to the encodeToString() method. We should use this approach with caution because if a class is not registered in our Json format before serialization, it will throw a SerializationException at runtime. Since this does not give us a compile-time error, we might not know if it’s safe to serialize certain objects until runtime.

4. Conclusion

Kotlinx-serialization simplifies class inheritance and polymorphic serialization, even when using abstract classes. By leveraging sealed classes, we can achieve type-safe closed polymorphic serialization using annotations to configure behavior.

When we can’t apply the sealed classes approach, we can use open polymorphism with polymorphic hierarchies defined at runtime. Although it’s less safe, it’s more flexible when we aren’t in full control of our code base.