kotlinx.serialization in Kotlin- All You Need To Know

In this article, we will cover the most important scenarios of kotlinx.serialization in Kotlin and how to use it to your advantage.
Image is a thumbnail for the article about kotlinx.serialization in Kotlin and contains Kotlin logo in the foreground and a blurred photo of a city in the background.

If you are working with Kotlin, then it is a matter of time before you will face a need to serialize and deserialize your data. In this topic, I am going to cover the most important scenarios of kotlinx.serialization in Kotlin and how to use it to your advantage in your projects.

If you haven’t heard about it, then Kotlin Serialization is a cross-platform and multi-format framework built for this specific needs. It can be used with practically any Kotlin-based project, such as Android applications, Ktor applications, and Multiplatform (Common, JS, Native). 

Setting Up

To set up kotlinx.serialization in our project, we must add the following lines in your build.gradle.kts.

plugins {

    kotlin("jvm") version "1.9.20" // or kotlin("multiplatform")

    kotlin("plugin.serialization") version "1.9.20"

}

This will add the plugin.

Following, let’s add the implementation of the library itself:

repositories {

    mavenCentral()

}

dependencies {

    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.1")

}

Now we’re ready to go!

Basic Serialization

Simple Objects

You may not used this library yourself but you might have seen a @Serializable annotation before.

This is practically all we’re going to need to set up a serialization for a particular class, like this: 

@Serializable
data class User(
  val userId: Int,
  val userName: String
)

Now, to manually serialize it, we only need to use a Json.encodeToString().

For example:

val data = User(userId = 1, userName = "Alice")
println(
  Json.encodeToString(data)
)

The output would be the following:

{"userId":1,"userName":"Alice"}

On the other hand, to deserialize something, we’ll use Json.decodeFromString():

val data = """  
  {
    "userId": 1,
    "userName":"Alice"
  }
"""  

println(
  Json.decodeFromString(
    User.serializer(), 
    data
  )
)

The output:

User(userId=1, userName=Alice)

kotlinx.serialization has also some useful functions to work with InputStream rather than with String. However, they are experimental at the moment and require using @OptIn(ExperimentalSerializationApi::class).

What am I talking about here? The Json.encodeFromStream() and Json.decodeFromStream(), which come in handy when we are dealing with Files.

Let’s take a look at how we can get data from the file without reading it and converting it to String. It saves lots of computation time, particularly on a big scale:

@OptIn(ExperimentalSerializationApi::class)
fun deserializeFile(file: File): User {
  return Json.decodeFromStream(
    User.serializer(),
    file.inputStream()
  )
}

Similarly, to encode it to a file we will use the encodeToStream

@OptIn(ExperimentalSerializationApi::class)
fun serializeFile(user: User, file: File) {
  Json.encodeToStream(
    User.serializer(),
    user,
    file.outputStream()
  )
}

Nested(Referenced) Objects

As the next step, let’s talk about nested serialization.

Highly likely, that some of your classes have a property that is another class.

In such a case, there is practically no difference from the previous section, just all of our classes have to have a @Serializable annotation:

@Serializable
data class Profile(
  val id: Int,
  val user: User
)

@Serializable
data class User(
  val userId: Int,
  val userName: String
)

Let’s check it out like this:

val originalProfile = Profile(
  id = 123, 
  user = User(userId = 1, userName = "Alice")
)
  
println( 
  Json.encodeToString(
    Profile.serializer(), 
    originalProfile
  )
)  

val jsonString = """  
  {
    "id":123,
    "user": {
      "userId": 1,
      "userName":"Alice"
    }
  }
"""  

val deserializedProfile = Json.decodeFromString(
  Profile.serializer(), 
  jsonString
)
  
println(deserializedProfile)

The output:

{"id":123,"user":{"userId":1,"userName":"Alice"}}
Profile(id=123, user=User(userId=1, userName=Alice))

Lists

To serialize the whole List, we can use the ListSerializer constructor to create a serializer for any type of list, as long as we provide a serializer for the element type.

For example:

val originalUsers = listOf(
  User(userId = 1, userName = "Alice"),
  User(userId = 2, userName = "Bob")
)  

println(
  Json.encodeToString(
    ListSerializer(User.serializer()), 
    originalUsers
  )
)  

val jsonString = """ 
  [
    {"userId":1,"userName":"Alice"},
    {"userId":2,"userName":"Bob"}
  ] 
"""  

val deserializedUsers = Json.decodeFromString(
  ListSerializer(User.serializer()), 
  jsonString
)  

println(deserializedUsers)

That code will give us:

[{"userId":1,"userName":"Alice"},{"userId":2,"userName":"Bob"}]
[User(userId=1, userName=Alice), User(userId=2, userName=Bob)]

Generics

Basically, generics serialization works the same as nested objects.

The kotlinx.serialization has type-polymorphic behavior, which means that JSON relies on the actual type parameter. It will be compiled successfully if the actual generic type is a serializable class.

To check that, let’s modify our classes:

@Serializable
data class Profile<T>(
  val id: T,
  val user: Wrapper<T>
)

@Serializable
data class Wrapper<T>(val contents: T)

Now we can check how it will work with different T for user fields:

val profile1 = Profile(
  1, 
  Wrapper(
    User(userId = 1, userName = "Alice")
  )
)  
val profile2 = Profile(
  2, 
  Wrapper(42)
)  
  
val jsonProfile1 = Json.encodeToString(profile1)  
val jsonProfile2 = Json.encodeToString(profile2)  
  
println(jsonProfile1)  
println(jsonProfile2)  
  
val jsonString1 = """ 
  {
    "id": 1,
    "user": {
      "contents": {
        "userId": 1,
        "userName": "Alice"
      }
    }
  } 
"""  
val jsonString2 = """ 
  {
    "id": 2,
    "user": {
      "contents": 42
    }
  } 
"""  
  
val deserializedProfile1 = Json.decodeFromString(
  Profile.serializer(
    User.serializer()
  ),
  jsonString1
)  
val deserializedProfile2 = Json.decodeFromString(
  Profile.serializer(
    Int.serializer()
  ), 
  jsonString2
)  
  
println(deserializedProfile1)  
println(deserializedProfile2)

There will be the following output: 

{"id":1,"user":{"contents":{"userId":1,"userName":"Alice"}}}
{"id":2,"user":{"contents":42}}
Profile(id=1, user=Wrapper(contents=User(userId=1, userName=Alice)))
Profile(id=2, user=Wrapper(contents=42))

Customizing serialization and deserialization

In this paragraph, we are gonna talk about making your objects a little bit appealing with variable behavior to adjust our specific needs.

Custom names

As you have noticed, the basic name of a field is taken by default.

To create a custom name, the only thing we need is a @SerialName annotation:

@Serializable
data class User(
  @SerialName("id") val userId: Int,
  @SerialName("login") val userName: String
)

Now let’s use it as we used in the example at the beginning:

val data = User(userId = 1, userName = "Alice")
println(
  Json.encodeToString(data)
)

We’ll get:

{"id":1,"login":"Alice"}

But we must be careful- this works both ways.

After the change, we must make sure that the incoming JSON contains the names that we mentioned in the @SerialName:

val data = """{"id":1,"login":"Alice"}"""         //correct
val data = """{"userId":1,"userName":"Alice"}"""  //wrong

Default values

Now things get a little bit trickier. Default values are not encoded by default.

So, if we want them to, we need the @EncodeDefault annotation:

@Serializable
data class User(
  val userId: Int,
  @EncodeDefault val userName: String = "user"
)

This way, we could omit a userName and the serialization library will use the default value:

val data = User(userId = 1)
println(
  Json.encodeToString(data)
)

Let’s see:

{"userId":1,"userName":"user"}

Excellent, works as expected!

But here is a different situation. What if we want to deserialize a JSON with some optional fields?

For it to compile properly, we must provide a default value for these fields:

@Serializable
data class User(
  val userId: Int,
  val userName: String = "user"
)

Now we can deserialize an object like this: 

val data = """
  {
    "userId": 1
  }
"""
  
println(
  Json.decodeFromString(
    User.serializer(),
    data
  )
)

Output: 

User(userId=1, userName=user)

On the other hand, if we want an optional field to be present in JSON, we may use a @Required annotation:

@Serializable
data class User(
  val userId: Int,
  @Required val userName: String = "user"
)

If we use this class with the code before, we will get an error, which can be useful in some scenarios:

Exception in thread “main” kotlinx.serialization.MissingFieldException: Field ‘userName’ is required for type with serial name ‘com.example.User’, but it was missing

Nulls

Kotlin’s language is known for its type safety. The kotlinx.serialization knows it as well.

We cannot decode a null value into a non-nullable property or we get an exception. For example, for this class:

@Serializable
data class User(
  val userId: Int,
  val userName: String
)

We cannot expect to serialize this object below:

val data = """
  {
    "userId": 1,
     "userName": null
  }
"""
  
println(
  Json.decodeFromString(
    User.serializer(),
    data
  )
)

It will result in an error.

To avoid it, we should do this instead:

@Serializable
data class User(
  val userId: Int,
  val userName: String?
)

This time, our result will be: 

User(userId=1, userName=null)

As we can see, default values are not encoded by default.

So to get a missing property from JSON not as an optional but as a null value, we have to use @EncodeDefault:

@Serializable
data class User(
  val userId: Int,
  @EncodeDefault val userName: String? = null
)

That is what we’ll see for our class:

{"userId":1,"userName":null}

However, if we forget the annotation, this is what we will get:

{"userId":1}

Advanced Serialization

This section is probably what you are looking for if you have a complex project.

These are the most sophisticated tricks of kotlinx.serialization, but the most useful, though. And now we are going to understand how they work.

From now on everything becomes more complex. So we have to establish ground rules and basic terms.

What Exactly Is a Polymorphism?

Polymorphism in Kotlin is the ability of an object or a function to have different forms or behaviors depending on the context. A polymorphic object can belong to different classes and respond to the same method call in different ways.

There are two types of polymorphism in Kotlin: compile-time and run-time:

  • Compile-time polymorphism, also known as static polymorphism, is achieved through function overloading. It allows the name of functions, i.e., the signature, to be the same but return type or parameter lists to be different.
  • Run-time polymorphism, or dynamic polymorphism, is achieved through function overriding and inheritance. In the run-time polymorphism, the compiler resolves a call to overload methods at the runtime.

But there is more. For serialization, we are going to talk about different approaches. To serialize an object, we should know what that object is in the first place and can its behavior can be changed. So there are 2 more types of polymorphism:

  • Closed polymorphism means that the behavior of an object is fixed and cannot be changed by subclasses or external factors.
  • Open polymorphism means that the behavior of an object can be modified or extended by subclasses or external factors.
    This is how the library sees polymorphism for the most part.

The Practice Part

First things first, let’s talk about closed polymorphism in kotlinx.serialization.

The first and the most obvious thing to do is to make all of our classes serializable:

@Serializable
open class User(
  val userId: Int,
  val userName: String?
)

@Serializable
@SerialName("admin")
class Admin(
  val adminId: Int,
  val adminName: String?,
  val adminRole: String
) : User(adminId, adminName)

Let’s check it out: 

fun serializeAdmin() {
  val admin: User = Admin(
    adminId = 1,
    adminName = "Alice",
    adminRole = "Boss"
  )

  println(
    Json.encodeToString(admin)
  )
}

Everything works as expected, our Admin is indeed a user: 

{"userId":1,"userName":"Alice"}

But what will happen if we try to serialize an Admin object:

fun serializeAdmin() {
  val admin = Admin(
    adminId = 1,
    adminName = "Alice",
    adminRole = "Boss"
  )

  println(
    Json.encodeToString(admin)
  )
}

Let’s find out:

{"userId":1,"userName":"Alice","adminId":1,"adminName":"Alice","adminRole":"Boss"}

So you have to keep that in mind. This may be a bit odd.
Following this, I want to cover sealed classes as well. Not because I’m a huge fan of it, which I am, but because of its natural behavior (we know all of its children at the runtime) that helps our process:

@Serializable
sealed class User {
  abstract val userId: Int
  abstract val userName: String?
}

@Serializable
@SerialName("admin")
class Admin(
  override val userId: Int,
  override val userName: String?,
  val adminRole: String
) : User()

We are going to check our first scenario: 

fun serializeAdmin() {
  val admin: User = Admin(
    userId = 1,
    userName = "Alice",
    adminRole = "Boss"
  )

  println(
    Json.encodeToString(admin)
  )
}

As expected, now we know the type of our user beforehand: 

{"type":"admin","userId":1,"userName":"Alice","adminRole":"Boss"}

For a closed polymorphism, things get different. Now we have to create a SerializersModule and provide explicit subclasses that are to be serialized.

Let’s create an additional class to show this in action:

@Serializable
abstract class User {
  abstract val userId: Int
  abstract val userName: String?
}

@Serializable
@SerialName("admin")
class Admin(
  override val userId: Int,
  override val userName: String?,
  val adminRole: String
) : User()

@Serializable
@SerialName("guest")
class Guest(
  override val userId: Int,
  override val userName: String?,
  val guestEmail: String?
) : User()

Now comes the module:

val module = SerializersModule {
  polymorphic(User::class) {
    subclass(Admin::class, Admin.serializer())
    subclass(Guest::class, Guest.serializer())
  }
}

We’ve used a Json instance for our needs before. Now we have to create a specific instance with our module:

val format = Json { serializersModule = module }

With that done, let’s check it:

fun serializeAdmin() {
  val admin: User = Admin(
    userId = 1,
    userName = "Alice",
    adminRole = "Boss"
  )
  val guest: User = Guest(
    userId = 1,
    userName = "Alice",
    guestEmail = "guest@email.com"
  )

  println(format.encodeToString(admin))
  println(format.encodeToString(guest))
}

And this time, we get the following results: 

{"type":"admin","userId":1,"userName":"Alice","adminRole":"Boss"}
{"type":"guest","userId":1,"userName":"Alice","guestEmail":"guest@email.com"}

We can serialize classes as long as we provide corresponding subclasses.

Of course, there is a lot to cover, for example, multiple superclasses or interfaces. But let’s stop here, maybe I’ll cover it explicitly in the other article. (Let me know if you are interested in this 🙂 )

Custom serializers

For some classes, happens that the default serialization method does not fulfill our needs. Or we want to create one that reflects a unique class utilization approach. For this purpose, custom serializers are our best friends. The most common examples are Date and Color. I’m gonna focus on Date.

Basically, there are 3 main parts of our custom serializer. Let’s check how the serializer for Date might look like:

object DateSerializer : KSerializer<Date> {
  private val dateFormat = SimpleDateFormat("yyyy-MM-dd 'T' HH:mm:ss.SSSZ")

  override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING)

  override fun serialize(encoder: Encoder, value: Date) {
    encoder.encodeString(dateFormat.format(value))
  }

  override fun deserialize(decoder: Decoder): Date {
    return dateFormat.parse(decoder.decodeString())
  }
}

We can already see all the necessary parts.

  • The serialize function is used to turn an object into a sequence of simple values. It takes an Encoder and a value as inputs. It calls the encodeXxx functions of the Encoder to make the sequence. There is a different encodeXxx function for each simple type.
  • The deserialize function is used to turn a sequence of simple values back into an object. It takes a Decoder as input and returns a value. It calls the decodeXxx functions of the Decoder to get the sequence. These functions match the encodeXxx functions of the Encoder.
  • The descriptor property is used to tell how the encodeXxx and decodeXxx functions work. This helps the format to know what methods to use. Some formats can also use it to make a schema for the data.

For our purposes, we can simplify the class. If there is no particular need in the descriptor, we can use a @Serializer(forClass = …):

@Serializer(forClass = Date::class)
object DateSerializer : KSerializer<Date> {
  private val dateFormat = SimpleDateFormat("yyyy-MM-dd 'T' HH:mm:ss.SSSZ")

  override fun serialize(encoder: Encoder, value: Date) {
    encoder.encodeString(dateFormat.format(value))
  }

  override fun deserialize(decoder: Decoder): Date {
    return dateFormat.parse(decoder.decodeString())
  }
}

To implement our custom serializer we should use a @Serializable(with = …) annotation for the corresponding property:

@Serializable
data class Event(
  val name: String,
  @Serializable(with = DateSerializer::class)
  val date: Date
)

Let’s see that in action: 

val event = Event("Birthday Party", Date())  

val json = Json.encodeToString(event)  

println(json)

With the result: 

{"name":"Birthday Party","date":"2023-11-23 T 17:12:36.069+0300"}

As we can see, it has successfully been serialized to the desired date format.

Contextual serialization

Sometimes we need to change how we write objects as JSON at run-time, not just at compile-time, as we spoke before. This is called contextual serialization.

We can use the @Contextual annotation on a class or a property to tell Kotlin to use the ContextualSerializer class. This class will choose the right serializer for the object based on the context. We are going to use the previous serializer:

object DateAsStringSerializer : KSerializer<Date> {
  private val dateFormat = SimpleDateFormat("yyyy-MM-dd 'T' HH:mm:ss.SSSZ")

  override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING)

  override fun serialize(encoder: Encoder, value: Date) {
    encoder.encodeString(dateFormat.format(value))
  }

  override fun deserialize(decoder: Decoder): Date {
    return dateFormat.parse(decoder.decodeString())
  }
}

And our class will be looking like this:

@Serializable
data class Event(
  val name: String,
  @Contextual val date: Date
)

Now we need to create a SerializersModule in which we need to specify a serializers should be used for our contextually-serializable classes.

We can simply wrap our serializer in contextual function inside the module:

private val module = SerializersModule {
  contextual<Date>(DateAsStringSerializer)
}  

Now we create a format out of Json with our module:

val format = Json { serializersModule = module }

Using our format of Json earlier, we’ll get our run-time serialization: 

val event = Event("Birthday Party", Date())  

val json = format.encodeToString(event)  

println(json)

With an expected output: 

{"name":"Birthday Party","date":"2023-11-23 T 23:20:03.421+0300"}

kotlinx.serialization Summary

And that’s all for this article about serialization in Kotlin with kotlinx.serialization.

In the upcoming articles, we will get back to this topic and learn how to apply this knowledge with Ktor, so don’t forget to join the free newsletter to not miss it!

Lastly, if you would like to learn more about kotlinx, then you can find lots of useful information on the official documentation of kotlinx

Share this:

Related content

Newsletter
Image presents 3 ebooks with Java, Spring and Kotlin interview questions.

Never miss any important updates from the Kotlin world and get 3 ebooks!

You may opt out any time. Terms of Use and Privacy Policy