Article

Intro to Polymorphism with Kotlinx.Serialization

David Perez

September 12, 2023

Polymorphic structures can come in many different forms; not all of them are made equally and, in some cases, you may have to deal with multiple types in the same project. Luckily Kotlinx.Serialization has a few tools to handle the most common forms of polymorphic data.



Modeling Polymorphism

There are 2 different ways you can model polymorphic JSON in code. Both of them have strengths and weaknesses that can be used for different purposes.

  • Sealed Classes: Represents sealed (or closed) polymorphism and requires a little less setup to function.
  • Abstract & Open classes: Represents open polymorphism and requires extra setup but provides the inherent flexibility of abstract classes.

We will start with sealed classes and move to abstract at the end.



Class Discriminator

The most straightforward tool when deserializing polymorphic data to sealed classes is the classDiscriminator. It requires the least amount of code to make it work but demands that the JSON of your app contain a specific type identifier. The classDiscriminator is a property on the Json class that indicates the name of the JSON property that identifies which class variant the JSON should be deserialized to. By default this value is "type” but you may want to customize it for your needs.

For example, here is a simple example of an array of polymorphic objects that could be sent to your app:

[
{
"type": "subclass_a",
"id": 12345,
"dataA": "Hello"
},
{
"type": "subclass_b",
"id": 54321
"dataB": 100
},
{
"type": "subclass_a",
"id": 98765,
"dataA": "Goodbye"
}
]

As you can see, there are really two different types of objects here with their own unique data associated with them ("dataA" and "dataB", modeled here as a string and integer respectively). Modeling them using a single class including all the possible properties of both would obfuscate the tight relationship between type A objects always containing "dataA" and type B objects always containing "dataB". The JSON above can instead be deserialized into a List<Base> and maintain their strong relationships as long as the models and the Json have the appropriate setup.

Json {
classDiscriminator = "type"
}

@Serializable
sealed class Base {
abstract val id: Int

@Serializable
@SerialName("subclass_a")
data class SubclassA(
@SerialName("id") override val id: Int,
@SerialName("dataA") val dataA: String,
) : Base()

@Serializable
@SerialName("subclass_b")
data class SubclassB(
@SerialName("id") override val id: Int,
@SerialName("dataB") val dataB: Int,
) : Base()
}

The @SerialName annotation on each of the implementing subclasses is matched to the value of the type property to tie it all together. This means that when the type has a value of "subclass_a”, it will attempt to deserialize the model as a SubclassA class while a value of "subclass_b” will be deserialized to a SubclassB class.



Json Class Discriminator

Hopefully the classDiscriminator solution works for your needs but it has limitations. One major one is that the Json class can only support a single classDiscriminator and this can cause issues if you have inconsistent JSON modeling. Perhaps most of your polymorphic models use the default property key type but every once in a while, there is a model that uses data_type as the key. There is a pretty simple solution for that too:

Json {
classDiscriminator = "type"
}

@JsonClassDiscriminator("data_type")
@Serializable
sealed class Base {
abstract val id: Int

@Serializable
@SerialName("subclass_a")
data class SubclassA(
@SerialName("id") override val id: Int,
@SerialName("dataA") val dataA String,
) : Base()

@Serializable
@SerialName("subclass_b")
data class SubclassB(
@SerialName("id") override val id: Int,
@SerialName("dataB") val dataB: Int,
) : Base()
}

Looks identical, except for the one key line. The added annotation, JsonClassDiscriminator, allows you to override the default classDisciminator and parse models regardless of the value being used. This can be done for any number of your polymorphic models and adds that extra bit of specificity that can be useful for future developers.



Content Serializer

The discriminator, as defined by the classDiscriminator or the JsonClassDiscriminator, is a powerful tool but it makes a basic assumption: that there is some sort of type property to help identify the specific class you want to be using. This is not always the case and the solutions above will not be able to help you in those scenarios… but there is still a way!

First, let's see some new JSON without a "type” property:

[
{
"id": 12345,
"dataA": "Hello"
},
{
"id": 54321
"dataB": 100
},
{
"id": 98765,
"dataA": "Goodbye"
}
]

Our new JSON is simple but you can see that the models still contain unique payloads, the "dataA" and "dataB” properties that are specific to their subclass. This information is all we need to determine which type of model we want to serialize to.

object BaseSerializer : JsonContentPolymorphicSerializer<Base>(
Base::class,
) {
override fun selectDeserializer(
element: JsonElement,
): DeserializationStrategy<Base> {
val jsonObject = element.jsonObject
return when {
jsonObject.containsKey("dataA") -> SubclassA.serializer()
jsonObject.containsKey("dataB") -> SubclassB.serializer()
else -> throw IllegalArgumentException(
"$type is not a supported Base type.",
)
}
}
}

@Serializable(BaseSerializer::class)
sealed class Base {
...
}

This code works out well and since you have access to the entire JsonElement, you can customize it to use any manner of conditions when determining which serializer to use. There is also nothing stopping you from using this form of serializer when you do have a type property if you prefer to have a more hands-on approach when picking your serializer.

Json {
classDiscriminator = "type"
}

object BaseSerializer : JsonContentPolymorphicSerializer<Base>(
Base::class,
) {
override fun selectDeserializer(
element: JsonElement,
): DeserializationStrategy<Base> {
val json = element.jsonObject
val type = json.getValue("data_type").jsonPrimitive.content
return when (type) {
"subclass_a" -> SubclassA.serializer()
"subclass_b" -> SubclassB.serializer()
else -> throw IllegalArgumentException(
"$type is not a supported Base type.",
)
}
}
}

@Serializable(BaseSerializer::class)
sealed class Base {
...
}

Because you are manually taking over the process of parsing type information, one thing to be careful of here is that you must make sure that the classDiscriminator does not match otherwise you will get a runtime error when trying to deserialize. In this case, the classDiscriminator is set to type and our key is data_type so there is no conflict.



Sealed Versus Open

The previous approaches all work well and are exceptionally clear but they only illustrate sealed polymorphism. Open polymorphism is the other option and it is applied when using an abstract class or an open class. As a simple example, we can take our previous test class and change it to abstract:

Json {
classDiscriminator = "type"
}

@Serializable
abstract class Base {
abstract val id: Int

@Serializable
@SerialName("subclass_a")
data class SubclassA(
@SerialName("id") override val id: Int,
@SerialName("dataA") val dataA: String,
) : Base()

@Serializable
@SerialName("subclass_b")
data class SubclassB(
@SerialName("id") override val id: Int,
@SerialName("dataB") val dataB: Int,
) : Base()
}

You may expect this to work immediately but unfortunately this will not deserialize the models properly. When using open polymorphism you must register all the subclasses that can be deserialized. It is a relatively simple process but does incur some additional overhead. I suggest using sealed polymorphism when possible as it is the most straightforward to implement and maintain.

If we did want to make this work, we just need to add the registration code to the Json class.

Json {
classDiscriminator = "type"
serializersModule = SerializersModule {
polymorphic(Base::class) {
subclass(Base.SubclassA::class)
subclass(Base.SubclassB::class)
}
}
}

And voila! With the classes registered, everything will work as before. Important to note that any time you add a new implementation of your abstract class, you must update your registered classes.



The Wrap Up

There are multiple ways to handle open and sealed polymorphism when using Kotlinx.Serialization in your app. The handful of tools described above will provide the most flexibility while keeping the complexity as low as possible. No matter what your polymorphic JSON looks like, there should be a way to deserialize it.



David deserializes all sorts of polymorphic models at Livefront