Migration: Gson to Kotlinx.Serialization

David Perez
Published on MediumOctober 4th, 2023

There are plenty of apps out there that are old enough to have been written in Java originally, even if they aren’t anymore. This means that certain things, even if they were the right tool at the time, are no longer the correct tool. Even if the entire app has migrated to Kotlin, there is still a good chance that no one ever bothered to migrate off of Gson since it was working well enough. Eventually, you might find yourself trying to tackle that last bit of technical debt in the project and migrate to the newer tools. And this is where we begin.



The Models

Before you even introduce the Kotlin.Serialization library, you should begin looking at the transition from Java network models to Kotlin network models.

  • Network Request Models tend to be much easier since they are instantiated programmatically and all the usual Kotlin requirements will be handled.
  • Network Response Models are where things can be trickier than you think because Gson is building the objects via reflection and using unsafe constructors. Making sure you are not using default parameters, delegates, or the init block is important since none of those things will work while you are still using Gson. If you are curious about why those things do not work or would like a handy library to bridge the gap during this transition, I suggest you take a look at my previous article here.

Because of these limitations, it is important to keep your models simple while still using Gson; think basic properties and functions. Something like this:

data class ResponseModel(
@SerializedName("string_data") val stringData: String,
@SerializedName("int_data") private val _intData: Int?,
) {
val intData: Int get() = _intData ?: 15

fun getAllData(): String {
"$stringData_$intData"
}
}

I find that the hardest thing to avoid when using Gson is default parameters. Often times I find that I want there to be a value prescribed when the network response does not provide one, and the example above shows a simple way to make that work. This approach also works perfectly fine once the transition to Kotlinx.Serialization is complete, though it is a little verbose.

Once you have completely migrated to Kotlinx.Serialization, you can finally update your models to use idiomatic Kotlin and use true default values. Make sure you enable the encodeDefaults feature, otherwise it will not serialize the property when it is set to the default value.

Json {
encodeDefaults = true
}

@Serializable
data class ResponseModel(
@SerialName("string_data") val stringData: String,
@SerialName("int_data") val intData: Int = 15,
) {
fun getAllData(): String {
return "$stringData_$intData"
}
}

There are three changes that have been made to the model to work with Kotlinx.Serialization.

  • Serializable: The@Serializable annotation is now added to the model.
  • Serial Name: All usages of the @SerializedName annotation is replaced with the @SerialName annotation.
  • Default Arguments: A default value for the intData is added to the constructor to simplify the model.


The Adapters

Once you’ve migrated all the models, you need to look at any custom adapters that you may have introduced. For the average project, this is not terribly difficult to migrate as there are similar tools in Kotlinx.Serialization. The KSerializer is a nice replacement for Gson’s TypeAdapter. It provides you with an Encoder and Decoder which has similar functionality to the JsonWriter and JsonReader. For the purposes of this content, we will assume that you are reading and writing JSON, so it will always be a JsonEncoder and JsonDecoder.

For the simplest of cases, the differences in code will be minimal between Gson and Koltinx.Serialization. Let’s start with a little JSON representing some dimensions:

{
"dimens": "10x15",
"unit": "ft"
}

Now we want to parse this into a model that includes the width and length as two separate properties, something like this:

data class Area(
@SerializedName("dimens") val dimension: Dimension,
@SerializedName("unit") val unit: String,
)

data class Dimension(
@SerializedName("length") val length: Int,
@SerializedName("width") val width: Int,
)

To accomplish this sort of task in Gson, we might have an adapter that looks like this:

class DimensionTypeAdapter : TypeAdapter<Dimension>() {
override fun read(reader: JsonReader): Dimension {
val dimensionsList = reader.nextString().split("x")
return Dimension(
width = dimensionsList[0].toInt(),
length = dimensionsList[1].toInt(),
)
}

override fun write(writer: JsonWriter, value: Dimension) {
writer.value("${value.width}x${value.length}")
}
}

Once converted to a KSerializer, the code is almost identical:

class DimensionSerializer : KSerializer<Dimension> {
override val descriptor: SerialDescriptor
get() = PrimitiveSerialDescriptor(
serialName = "Dimension",
kind = PrimitiveKind.STRING,
)

override fun deserialize(decoder: Decoder): Dimension {
val dimensionsList = decoder.decodeString().split("x")
return Dimension(
width = dimensionsList[0].toInt(),
length = dimensionsList[1].toInt(),
)
}

override fun serialize(encoder: Encoder, value: Dimension) {
encoder.encodeString("${value.width}x${value.length}")
}
}

Just be sure to update the model appropriately to associate the new serializer with the model:

@Serializable
data class Area(
@SerialName("dimens") val dimension: Dimension,
@SerialName("unit")val unit: String,
)

@Serializable(with = DimensionSerializer::class)
data class Dimension(
@SerialName("length") val length: Int,
@SerialName("width") val width: Int,
)

Just as before, the model now uses the @Serializable and @SerialName annotations. The bigger difference is that we have added the DimensionSerializer to the @Serializable annotation indicating that this model should always be serialized with the customer serializer.



In some cases, you may have to serialize a class that you cannot directly annotate. This is usually because the class in question is from a library. A common example might be the Java ZonedDateTime class. For something like this, you would still need to make a custom KSerializer but because you cannot annotate the class, you must annotate the properties with @Contextual instead:

Json {
serializersModule = SerializersModule {
contextual(ZonedDateTime::class, ZonedDateTimeSerializer())
}
}

@Serializable
data class CustomTimeClass(
@Contextual @SerialName("datetime") val time: ZonedDateTime,
)

Note that there is more than just the annotation: we also needed to add the serializer to the serializersModule. This works fairly well but if you prefer to keep your Json class empty, you can specify the exact serializer on the property by replacing the @Contextual annotation with @Serialization(with = ZonedDateTimeSerializer::class).



JSON Manipulation

Sometimes the JSON you get is not quite the style you want it to be in. Or perhaps the JSON is just inconsistent and you want to be able to handle the diversity in one place. There is a solution for that too: the JsonTransformingSerializer is specifically made as an easy way to handle this sort of problem.

For example, here is an array of places and their associated location (using coordinates). The location is what we care about here; notice that it can be represented in a variety of different formats, which makes it less than desirable to work with:

[
{
"name": "home",
"location": null
},
{
"name": "office",
"location": [40.737508, -73.937633]
},
{
"name": "cafe",
"location": {
"latitude": 40.378051,
"longitude": -73.336739
}
}
]

When using Gson, you may have created a custom serializer that implemented both the JsonDeserialzer and the JsonSerializer interfaces. This granted you direct access to the JsonElement where you could then construct the model as you saw fit:

class LocationSerializer : JsonSerializer<Location>, JsonDeserializer<Location> {
override fun deserialize(
json: JsonElement?,
typeOfT: Type?,
context: JsonDeserializationContext?,
): Location = when (json) {
is JsonObject -> Location(
latitude = json.get("latitude").asDouble,
longitude = json.get("longitude").asDouble,
)
is JsonArray -> if (list.size == 2) {
Location(
latitude = json[0].asDouble,
longitude = json[1].asDouble,
)
} else {
throw IllegalArgumentException(
"Invalid JSON format for Location",
)
}
else -> throw IllegalArgumentException(
"Invalid JSON format for Location",
)
}

override fun serialize(
src: Location,
typeOfSrc: Type?,
context: JsonSerializationContext,
): JsonElement = JsonArray(2).apply {
add(src.latitude)
add(src.longitude)
}
}

The above serializer gives us a chance to directly translate the Location object to and from JSON elements. We can translate the above code into a JsonTransformingSerializer which functions in a similar manner:

class LocationTransformingSerializer : JsonTransformingSerializer<Location>(
tSerializer = Location.serializer(),
) {
override fun transformDeserialize(
element: JsonElement,
): JsonElement = when (element) {
is JsonObject -> element
is JsonArray -> if (element.size == 2) {
JsonObject(
mapOf(
"latitude" to element[0],
"longitude" to element[1],
),
)
} else {
throw IllegalArgumentException(
"Invalid JSON format for Location",
)
}
else -> throw IllegalArgumentException(
"Invalid JSON format for Location",
)
}

override fun transformSerialize(
element: JsonElement,
): JsonElement = when (element) {
is JsonArray -> element
is JsonObject -> JsonArray(
listOf(
element.getValue("latitude"),
element.getValue("longitude"),
)
)
else -> throw IllegalArgumentException(
"Invalid JSON format for Location",
)
}
}

The biggest difference between the old code and this new serializer is that we never actually deal with a Location object. They both start and end with a JsonElement; we inspect the JsonElement to determine what type of data we have received and then transform it into the structure we want. Once the serializer is ready, just annotate models:

@Serializable
data class Building(
@SerialName("name") val name: String,
@Serializable(LocationTransformingSerializer::class)
@SerialName("location") val location: Location?,
)

@Serializable
data class Location(
@SerialName("latitude") val latitude: Double,
@SerialName("longitude") val longitude: Double,
)

This solution is fine but requires you to annotate each spot in the app that needs to use this custom serializer. Unfortunately, you cannot apply the serializer to the class itself (like we did with the Dimension class) because we are referencing the default-generated serializer when constructing the LocationTransformingSerializer. To override the serializer for the entire class, we would need to create a custom serializer for the Location class and pass that into the JsonTransformingSerializer.



Polymorphism

Handling polymorphic JSON in Gson can be a bit of a hassle because there is nothing included in the library for handling it. You always need to write a custom adapter in order to handle the unique payloads or copy the RuntimeTypeAdapterFactory, which still requires additional setup. Take this JSON as an example:

[
{
"type": "feed_message",
"id": 1234,
"message": "Hello!"
},
{
"type": "feed_icon",
"id": 1234,
"icon_url": "www.exampleicon.com"
},
{
"type": "feed_bonus",
"id": 1234,
"bonus": "bonus!!!"
}
]

In order to parse this, you would need to create a custom JsonDeserializer that can identify the unique payloads and defer to the correct deserializer. It ends up being fairly simple but more of a hassle than you want it to be.

class FeedItemSerializer : JsonDeserializer<FeedItem>, JsonSerializer<FeedItem> {
override fun deserialize(
json: JsonElement,
typeOfT: Type?,
context: JsonDeserializationContext,
): FeedItem {
val jsonObject = json.asJsonObject
val feedItemType = jsonObject.get("type").asJsonPrimitive.asString
return when (feedItemType) {
"feed_bonus" -> context.deserialize<FeedItem>(
jsonObject,
FeedBonus::class.java,
)
"feed_icon" -> context.deserialize<FeedItem>(
jsonObject,
FeedIcon::class.java,
)
"feed_message" -> context.deserialize<FeedItem>(
jsonObject,
FeedMessage::class.java,
)
else -> throw IllegalArgumentException(
"Invalid JSON format for FeedItem",
)
}
}

override fun serialize(
src: FeedItem,
typeOfSrc: Type?,
context: JsonSerializationContext,
): JsonElement = when (src) {
is FeedBonus -> context.serialize(src, FeedBonus::class.java)
is FeedIcon -> context.serialize(src, FeedIcon::class.java)
is FeedMessage -> context.serialize(src, FeedMessage::class.java)
}
}

This can be used to construct models like this:

sealed class FeedItem {
abstract val id: Int
abstract val type String

data class FeedBonus(
@SerializedName("id") override val id: Int,
@SerializedName("type") override val type: String,
@SerializedName("bonus") val bonus: String,
) : FeedItem()

data class FeedIcon(
@SerializedName("id") override val id: Int,
@SerializedName("type") override val type: String,
@SerializedName("icon_url") val url: String,
) : FeedItem()

data class FeedMessage(
@SerializedName("id") override val id: Int,
@SerializedName("type") override val type: String,
@SerializedName("message") val message: String,
) : FeedItem()
}

There 3 main tools for handling polymorphism in Kotlinx.Serialization; each has some advantages and disadvantages depending on your JSON modeling.

  • Class Discriminator: This is a global property that can be found on the Json class; it allows you to denote which class to deserialize based on the specified property in the JSON. It is extremely easy to use and requires almost no setup. The primary limitation is that there is only one classDiscriminator, which can prove very problematic when dealing with inconsistent JSON models.
  • Json Class Discriminator: Extremely similar to the classDiscriminator but provides the solution to its main limitation. This is an annotation that can be used to specify a different property to be used when determining which class to deserialize.
  • Json Content Polymorphic Serializer: This type of custom KSerializer is going to look the closest to a JsonDeserializer you may be accustomed to writing for Gson. It provides a function, selectDeserializer, that allows you to inspect the JSON and determine which serializer should be used based on any arbitrary data you define.

Using the classDiscriminator and @JsonClassDiscriminator can remove the need for custom serializers completely but they only work when you have some sort of type property to key off of. The JsonContentPolymorphicSerializer is much more flexible since you can determine the correct serializer from any arbitrary data but does require you to create a new serializer.

The usage of Polymorphism is complex enough to warrant its own article, so I made one. For greater detail and examples, please check it out!



The End

With these tools and ideas in hand, you should be able to handle the most common issues that could be run into while migrating to Kotlinx.Serialization.

David deserialized all sorts of models at Livefront

We talk the talk.
See how we walk the walk.

Our Work