1. Overview
kotlinx-serialization allows us to serialize Kotlin objects to JSON, Protobuf, CBOR, and various other formats.
It’s an open-source project that provides an easy-to-use API and supports a wide range of formats for reflection-less serialization.
Kotlin 1.4.0 introduced the first stable version of JSON serialization, deprecating the previously utilized kotlinx-serialization-runtime library. The remaining supported serialization formats aren’t yet part of a stable release.
In this tutorial, we’ll focus on the basics of kotlinx-serialization for the JSON format. We’ll see how to use it in our projects, and learn how to create custom serializers to handle more complex data structures.
2. Getting Started With kotlinx-serialization
We need to add the kotlinx-serialization dependency to our project’s pom.xml.
Before that, let’s first set the compatible dependency versions:
<properties>
<kotlin.version>1.8.10</kotlin.version>
<serialization.version>1.5.0</serialization.version>
</properties>
Then we add the serialization plugin to the Kotlin compiler plugin:
<build>
<plugins>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
<configuration>
<compilerPlugins>
<plugin>kotlinx-serialization</plugin>
</compilerPlugins>
</configuration>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-serialization</artifactId>
<version>${kotlin.version}</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
This compiler plugin is responsible for automatically generating instances of serialization interfaces for classes marked with the @Serializable annotation.
Finally, let’s add the runtime dependency:
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-serialization-json</artifactId>
<version>${serialization.version}</version>
</dependency>
Once we’ve added the dependencies, we can start using kotlinx-serialization in our code.
3. Serializing Objects
The first step is to define the Kotlin data class that we want to serialize. We’ll create a simple data class called SerializablePerson that has three properties: firstName, lastName, and age:
@Serializable
data class SerializablePerson(
val firstName: String,
val lastName: String,
val age: Int,
) {
init {
require(firstName.isNotEmpty()) { "First name can't be empty" }
}
val fullName: String
get() = "$firstName $lastName"
val id by ::lastName
}
The @Serializable annotation is necessary for kotlinx-serialization to recognize the class and generate the serialization code. For this example, we’ve also introduced two additional properties to the SerializablePerson class – fullName, which is a computed property at runtime, and id which is of a delegated property type. Neither of these two fields allocates a variable in memory – the Java compiler simply compiles them to a getter method.
To serialize a SerializablePerson object to JSON, we can use the Json.encodeToString() method:
val person = SerializablePerson("John", "Doe", 30)
val json = Json.encodeToString(person)
This produces the following JSON output:
It’s important to note that kotlinx-serialization serializes only class properties with backing fields. It doesn’t serialize the fullName and id properties since they lack backing fields.
4. Deserializing Objects
Similarly, we can try to deserialize JSON back into a SerializablePerson object using the Json.decodeFromString() method. Unfortunately, it fails because we can’t serialize classes with delegated properties:
@Test
fun `fails to deserialize string to object with delegated property`() {
val json = """{"firstName":"John","lastName":"Doe","age":30}"""
val exception = assertFailsWith<NoSuchFieldError> {
Json.decodeFromString<SerializablePerson>(json)
}
}
If we omit the delegated id property from the SerializablePerson, creating a DeserializablePerson class for this example, deserialization succeeds:
@Serializable
data class DeserializablePerson(val firstName: String, val lastName: String, val age: Int) {
init {
require(firstName.isNotEmpty()) { "First name can't be empty" }
}
}
@Test
fun `deserializes string to object`() {
val json = """{"firstName":"John","lastName":"Doe","age":30}"""
val person = Json.decodeFromString<DeserializablePerson>(json)
assertEquals(DeserializablePerson("John", "Doe", 30), person)
}
The deserialization process works like a regular constructor invocation in Kotlin. It even calls init blocks, ensuring that we don’t get an invalid object as a result of the deserialization.
If we were to violate a precondition of the init block, we’d get an IllegalArgumentException:
@Test
fun `fails to deserialize string to object when json input is incorrect`() {
val json = """{"firstName":"","lastName":"Doe","age":30}"""
assertFailsWith<IllegalArgumentException> {
Json.decodeFromString<DeserializablePerson>(json)
}
}
This has an empty firstName value, so deserialization results in a runtime exception.
5. Using Custom Serializers
While kotlinx-serialization provides built-in support for many common data types, we may encounter situations where we need to serialize more complex data structures. In these cases, we can create custom serializers to handle the serialization and deserialization of our data.
5.1. Creating a Custom Serializer
To create a custom serializer, we need to implement the KSerializer interface. This requires us to implement two methods: serialize() and deserialize().
Let’s create a custom serializer for the LocalDateTime class, which kotlinx-serialization doesn’t directly support:
object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.String)
override fun serialize(encoder: Encoder, value: LocalDateTime) {
encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): LocalDateTime {
return LocalDateTime.parse(decoder.decodeString())
}
}
The descriptor property is crucial for determining the shape of the serialized form and its metadata. Encoders and decoders rely on the descriptor to examine the type and metadata of LocalDateTime‘s elements during serialization and to infer the schema or compare it with a predefined schema.
When the serialized data is in a primitive form, we should use primitive descriptors. So, in this case, as the serialized type of LocalDateTime is String, we use PrimitiveSerialDescriptor.
The serialize() method takes an Encoder object and the value to serialize and is responsible for writing the serialized data to the encoder. In this example, we convert the LocalDateTime object to a string and encode it as a string.
The deserialize() method takes a Decoder object and is responsible for reading the serialized data and returning the deserialized value. Here we parse the string back into a LocalDateTime object.
To use this custom serializer, we need to register it to the Json configuration with the SerializersModule, using the contextual() method:
val json = Json {
serializersModule = SerializersModule {
contextual(LocalDateTimeSerializer)
}
}
Then we can use it in our code to serialize LocalDateTime objects:
@Test
fun `serializes LocalDateTime with registered serializer`() {
val dateTime = LocalDateTime.parse("2023-04-24T15:30:00.123")
val serializedDateTime = json.encodeToString(dateTime)
assertEquals("\"2023-04-24T15:30:00.123\"", serializedDateTime)
}
We can also use our LocalDateTimeSerializer to deserialize from a string respectively:
@Test
fun `deserializes LocalDateTime with registered serializer`() {
val dateTime = LocalDateTime.parse("2023-04-24T15:30:00.123")
val deserializedDateTime = json.decodeFromString<LocalDateTime>("\"2023-04-24T15:30:00.123\"")
assertEquals(dateTime, deserializedDateTime)
}
Let’s suppose we tried to use an unsupported serializable type within some other class as a property. In that case, we’d get compile time error indicating that a serializer can’t be found for such type.
To avoid this error, we annotate the field with the @Serializable annotation and declare a KSerializer implementation.
For this example, we’ll make use of our previously created LocalDateTimeSerializer which serializes a LocalDateTime property within a data class:
@Test
fun `serializes LocalDateTime using annotation declared serializer`() {
@Serializable
data class LocalDateTimeWrapper(
@Serializable(with = LocalDateTimeSerializer::class)
val dateTime: LocalDateTime,
)
val dateTimeWrapper = LocalDateTimeWrapper(
dateTime = LocalDateTime.parse("2023-04-24T15:30:00.123")
)
val serializedDateTimeWrapper = Json.encodeToString(dateTimeWrapper)
assertEquals("{\"dateTime\":\"2023-04-24T15:30:00.123\"}", serializedDateTimeWrapper)
}
Notably, there’s no need to register the custom serializer with the Json configuration as we did before. Instead, we do it at compile time by using the @Serializable annotation on the dateTime property.
We can also swap this annotation with @Contextual if we wish to use the Json configuration created at runtime.
If a field annotated with @Contextual doesn’t have a registered serializer at runtime, it throws a SerializationException:
@Test
fun `throws exception serializing LocalDateTime with unregistered contextual serializer`() {
@Serializable
data class LocalDateTimeWrapper(
@Contextual
val dateTime: LocalDateTime,
)
val dateTimeWrapper = LocalDateTimeWrapper(
dateTime = LocalDateTime.parse("2023-04-24T15:30:00.123")
)
assertThrows<SerializationException> {
Json.encodeToString(dateTimeWrapper)
}
}
5.2. Using a Third-Party Library
We may also choose to use a third-party library that provides support for the serialization of certain data types. JetBrains provides us with the kotlinx-datetime for working with date and time. As of version 0.2.0, it supports serialization features for the kotlinx-serialization library out-of-the-box.
Also, we should note that, as the version suggests, JetBrains hasn’t released its major version yet and its API may change in the future.
To use it in our project, we must include kotlinx-datetime artifact as a dependency in the pom.xml:
<dependency>
<groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-datetime-jvm</artifactId>
<version>0.4.0</version>
</dependency>
We can now serialize and deserialize date and time classes without implementing a custom serializer as the library provides default ones for its implemented types. For example, we can serialize a LocalDateTime object without registering a custom serializer to the Json configuration:
import kotlinx.datetime.LocalDateTime
We should note that with this approach, date and time types are from kotlinx-datetime library, and not the usual java.time package.
6. Conclusion
In this article, we’ve explored kotlinx-serialization – a powerful library that provides an easy-to-use API for serializing and deserializing Kotlin objects. It supports a wide range of formats and provides built-in support for many common data types.
In addition, kotlinx-serialization allows us to create custom serializers to handle more complex data structures. If there is no out of the box support for a data type, we can implement the KSerializer interface and register the serializer with SerializersModule.
Overall, kotlinx-serialization is an essential tool for any Kotlin developer who needs to work with serialized data. Its intuitive API and flexible architecture make it a great choice for any project requiring serialization and deserialization capabilities.