Article
Migration: Gson to Kotlinx.Serialization
October 4, 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 oneclassDiscriminator
, 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 aJsonDeserializer
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