Kotlin Serialization — Json mistakes I made with Polymorphism and More.

Kerry Bisset
7 min read6 days ago

Have you ever had polymorphic serialization go wrong in your Kotlin projects? If so, you’re not alone. Polymorphic serialization, while incredibly powerful, can be tricky to get right. From unexpected type mismatches to subtle configuration issues, many pitfalls can lead to frustrating bugs and errors.

In this article, let's explore Kotlin Serialization, focusing on the Json object. I’ll provide practical guidance on how to use it effectively, especially when dealing with polymorphic serialized classes. By the end of this guide, you’ll be equipped with the knowledge to leverage Kotlin’s serialization capabilities.

If you have never used Kotlin serialization, I’ll cover some of the basics, show the nuances of the JSON object, and navigate the complexities of polymorphic serialization. Along the way, I’ll highlight common pitfalls and share best practices to help you avoid the usual mistakes.

Basics of Kotlin Serialization

Core Concepts

Kotlin Serialization is a powerful library that simplifies converting Kotlin objects to and from various data formats like JSON, ProtoBuf, and CBOR. At its core, serialization transforms data structures into a format that can be easily stored or transmitted, while deserialization reverses the process of converting that data back into a usable object.

The Json object in Kotlin Serialization plays a role in handling JSON data. It provides encoding methods (serialization) and decoding (deserialization) data, making it easier to work with JSON in a Kotlin-friendly way.

Setting Up

To start with Kotlin Serialization, include the necessary dependencies in your project. Here’s how you can set it up in a Gradle-based project:

Add the Kotlin Serialization Plugin:

[versions]
# Jetbrains and Kotlin Libraries
kotlin = "2.0.0"
kotlinx-serialization = "1.6.0"

[libraries]
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }

[plugins]
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

With these dependencies and configurations in place, you can use Kotlin Serialization in your project.

Apply the plugin

Root Project build.gradle.kts

plugins {
// this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.jetbrainsCompose) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.kotlinMultiplatform) apply false

// Apply plugin but not to all childern
alias(libs.plugins.kotlinSerialization) apply false

}

Module build.gradle.kts

plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsCompose)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.sqlDelight)
}

kotlin {
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = libs.versions.jvmTarget.get()
}
}
}

jvm("desktop")

sourceSets {
val commonMain by getting {
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
// The json serialization version
implementation(libs.kotlinx.serialization.json)

}
}
......

Basic Serialization and Deserialization

Here’s a simple example to demonstrate how serialization and deserialization work:

Define a Data Class:

@Serializable
data class User(val name: String, val age: Int)

Serialize an Object:

val user = User("John Doe", 30)
val jsonString = Json.encodeToString(user)
println(jsonString) // Output: {"name":"John Doe","age":30}

Deserialize a JSON String

val json = """{"name":"John Doe","age":30}"""
val user = Json.decodeFromString<User>(json)
println(user) // Output: User(name=John Doe, age=30)

Understanding the Json Object

Overview

The Json object in Kotlin Serialization is a central component that provides the functionality to encode and decode JSON data. It serves as a configurable entry point for all JSON serialization and deserialization operations, making it easier to work with JSON in a Kotlin-centric way.

The Json object is designed to be flexible and powerful, allowing you to handle various JSON data structures, including complex nested objects and polymorphic data. It also offers several configuration options that can be adjusted to suit the use case, ensuring that your JSON processing is efficient and accurate.

Configuration Options

One of the key strengths of the Json object is its tailorability. You can tailor its behavior to match the requirements of your application. Here are some of the most commonly used configuration options:

  • prettyPrint: When enabled, the output JSON is formatted with line breaks and indentation for readability. This is particularly useful for debugging and logging purposes.
val json = Json { prettyPrint = true }
  • isLenient: Allows the Json object to handle non-standard JSON inputs, such as unquoted keys or single quotes. This can be useful when dealing with JSON data that doesn't strictly adhere to the JSON standard.
val json = Json { isLenient = true }
  • ignoreUnknownKeys: When enabled, unknown keys in the input JSON are ignored during deserialization, preventing errors when the JSON structure doesn't exactly match the expected Kotlin data class.
val json = Json { ignoreUnknownKeys = true }
  • encodeDefaults: Controls whether properties with default values are included in the serialized JSON. By default, properties with default values are not included to reduce the size of the output JSON.
val json = Json { encodeDefaults = true }

Practical Examples

To illustrate the use of these configuration options, let’s look at a few practical examples:

Pretty Printing JSON:

@Serializable
data class User(val name: String, val age: Int)

val user = User("John Doe", 30)
val json = Json { prettyPrint = true }
val jsonString = json.encodeToString(user)
println(jsonString)

This will output a formatted JSON string that is easy to read:

{
"name": "John Doe",
"age": 30
}

Handling Lenient JSON Input:

val json = Json { isLenient = true }
// John Doe is in ''
val jsonString = """{name: 'John Doe', age: 30}"""
val user = json.decodeFromString<User>(jsonString)
println(user)

Ignoring Unknown Keys:

val json = Json { ignoreUnknownKeys = true }
val jsonString = """{"name": "John Doe", "age": 30, "unknownField": "value"}"""
val user = json.decodeFromString<User>(jsonString)
println(user)

Working with Polymorphic Serialization

Polymorphic serialization allows you to serialize and deserialize objects of different types with a common interface or superclass. This is particularly useful in scenarios where you need to handle a variety of related types in a flexible manner, such as when working with APIs that return heterogeneous data or when dealing with complex data models.

In Kotlin Serialization, polymorphism is achieved through annotations and special configurations. Properly handling polymorphic data requires careful setup to ensure all possible types are correctly serialized and deserialized.

Defining Polymorphic Classes

To define polymorphic classes in Kotlin, you start with a common base class or interface and then create various subclasses that extend or implement it. Here’s a simple example to illustrate this concept:

Define the Base Class and Subclasses:

@Serializable
sealed class Animal {
@Serializable
@SerialName("cat")
data class Cat(val name: String, val meowVolume: Int) : Animal()

@Serializable
@SerialName("dog")
data class Dog(val name: String, val barkVolume: Int) : Animal()
}

In this example, Animal is the base class, and Cat and Dog are subclasses that extend Animal. The @SerialName annotation specifies a unique identifier for each subclass in the serialized JSON.

Enable Polymorphic Serialization:

To enable polymorphic serialization, you need to configure the Json object accordingly:

val json = Json {
serializersModule = SerializersModule {
polymorphic(Animal::class) {
subclass(Animal.Cat::class, Animal.Cat.serializer())
subclass(Animal.Dog::class, Animal.Dog.serializer())
}
}
}

For this example, the JSON object needs a defined SerializersModule that registers the subclasses of Animal. This configuration allows the JSON object to correctly handle polymorphic serialization and deserialization.

Practical Examples

Let’s look at some practical examples of how to serialize and deserialize polymorphic data:

val cat = Animal.Cat("Whiskers", 5)
val dog = Animal.Dog("Buddy", 8)

val jsonCat = json.encodeToString(Animal.serializer(), cat)
val jsonDog = json.encodeToString(Animal.serializer(), dog)

println(jsonCat) // Output: {"type":"cat","name":"Whiskers","meowVolume":5}
println(jsonDog) // Output: {"type":"dog","name":"Buddy","barkVolume":8}

val cat = json.decodeFromString(Animal.serializer(), jsonCat)
val dog = json.decodeFromString(Animal.serializer(), jsonDog)

println(cat) // Output: Cat(name=Whiskers, meowVolume=5)
println(dog) // Output: Dog(name=Buddy, barkVolume=8)

Common Pitfalls and Best Practices

Working with polymorphic serialization can be challenging due to the potential for type mismatches and configuration errors. Here are some common pitfalls and best practices to keep in mind:

  • Forgetting To Register Module: SD
  • Ensure Unique Identifiers: Use the @SerialName annotation to provide unique identifiers for each subclass. This helps avoid confusion during deserialization.
  • Register All Subclasses: Make sure all subclasses are registered in the SerializersModule. Failure to do so will result in errors when attempting to deserialize objects of unregistered types.
  • Handle Unknown Types: To gracefully handle JSON data that contains unexpected or unknown types, consider enabling the ignoreUnknownKeys option.

Using Koin to Wrap the Json Object

When working with many polymorphic serialized classes, managing the Json object and ensuring all necessary modules are loaded can become complex. Recreating the Json object multiple times can lead to inefficiencies and increase the risk of missing required modules. Using a dependency injection framework like Koin can greatly address these challenges. Koin allows you to manage the Json object centrally and ensure it is configured correctly with all necessary modules.

Defining the Koin Module

Create a Koin module that provides the Json object configured with the necessary serializers and modules for polymorphic serialization:

val appModule = module {
single {
val serializersModule = SerializersModule {
polymorphic(Animal::class) {
subclass(Animal.Cat::class, Animal.Cat.serializer())
subclass(Animal.Dog::class, Animal.Dog.serializer())
}
}

Json {
serializersModule = serializersModule
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
}
}
}

Using the Json Object in Your Application

With Koin setup, the JSON object can be injected wherever you need it in your application. This ensures that the Json object is created once and is available globally, with all necessary modules correctly loaded.

Injecting the Json Object:

class MyRepository(private val json: Json) {
fun serializeAnimal(animal: Animal): String {
return json.encodeToString(Animal.serializer(), animal)
}

fun deserializeAnimal(jsonString: String): Animal {
return json.decodeFromString(Animal.serializer(), jsonString)
}
}

Wrap Up

This article explored the intricacies of working with Kotlin Serialization’s Json object, especially when dealing with polymorphic serialized classes. Starting with the basics of Kotlin Serialization, it examined the flexible configuration options of the Json object and delved into the complexities of polymorphic serialization. By understanding common pitfalls and adopting best practices, you can effectively manage serialization in Kotlin projects.

The importance of using a dependency injection framework like Koin to wrap the Json object was highlighted. This approach ensures efficient module loading and consistent configuration across applications, preventing issues with missing modules and improving overall performance.

By leveraging these techniques, you can easily create robust and maintainable code that handles serialization tasks. As development with Kotlin continues, remember to use the Json object wisely and consider integrating Koin to simplify dependency management.

Thank you for reading, and happy coding!

--

--