Using the Kotlin Serialization Library for Tough JSON Serialization

by Yarden Gavish

Lightricks
Lightricks Tech Blog
6 min readMay 31, 2022

--

Serialization — we can’t pretend it’s a topic that gets many hearts racing.

However, if you’re developing an app that passes files around (e.g. through a server) or saves information to a database, serialization is unavoidable.

Here at Lightricks, we develop photo and video editing apps that do both. In our time, we’ve had first-hand experience of the best (and worst) that Kotlin serialization has to offer.

We use the Kotlin serialization library, because it’s compatible with Kotlin, has a faster runtime than Gson or Moshi, and can run on other platforms with multiplatform support. Usually, the default serializer does all of the work for us. But, when the JSON files differ from the way we want to represent them in Kotlin, there are some edge cases that force us to add complexities to the default serialization.

As part of our latest feature, we needed to create cross-platform video projects. This required creating platform agnostic JSON files representing these projects. Each platform then received JSON files, with no (or limited) ability to change them — making the conversion to in-memory video projects tricky.

A graph showing how data is serialized into JSON files which can be shared by Android and iOS apps.
JSON file is used across both platforms, iOS and Android.

In this post, we’ll explain how to handle tough serialization when dealing with JSON files that don’t naturally map directly to Kotlin objects. Specifically, we’ll explore dealing with polymorphism and changing data types during deserialization, using a JsonTransformingSerializer.

What’s up with polymorphism?

Let’s look at an example of a video project object. This is a simple video project, made up of a list of layers, specifically text, audio and video. The JSON file would look like this:

From the format of the JSON, we can see that polymorphism is needed. There must be some general abstract layer type which can be held in a single list. Each layer can be audio, video or text. So we can try and define the VideoProject as follows:

It’s made up of a list of layers, where Layer is an abstract class.

Each specific layer inherits from this abstract class. They have some of the same fields (like startTime and duration), but they have some layer-specific fields as well.

This is nice enough, but it is not (de)serializable yet.

To make this serializable, we first need to understand a little about how the Kotlin serializer handles polymorphism. The Kotlin serializer differentiates between different types by the keyword type. This indicates to the serializer which subclass to serialize. In the example above, if the type is AudioLayer, then the serializer knows to look for the volume field.

Looking at the above JSON, we see that the type field exists, meaning the Kotlin serializer will use this field to differentiate between the different subclasses. Specifically, the serializer expects the type field to exactly match a specific class.

In this case, we want to give the subclasses in Kotlin style-appropriate (camel-case) and meaningful names such as AudioLayer (rather than audio as found in the JSON). To do this, we can add the @SerialName annotation to make the layer subtypes match the type found in the JSON. For the audio layer, this would look like:

This annotation, which can be used in the class level or field level, is useful whenever we want the serialized name to be different from the Kotlin name, or to protect against any accidental refactoring. For example, if one day in the future this class is refactored to MusicLayer, the @SerialName annotation keeps the deserialization working. Without it, any name refactoring of a class or field will result in difference between the JSON and the Kotlin object, resulting in a failed deserialization.

And now back to our polymorphic serialization.

If all of the layers are known in advance, then making Layer a serializable sealed class is enough. Like this:

However, if the class cannot be sealed, we need to register subclasses manually. This would mean creating an object, such as ProjectSerializer, which includes all of the subclasses. Like this:

Then, when encoding or decoding, we would need to use the projectJson inside of ProjectSerializer, as follows:

This works because we can use the encoded field type to discriminate between the different subclasses. However, we cannot assume that this will always be the case, especially if the JSON was not created by the Kotlin serializer.

What would happen if in the example above there was no type field, but instead the field describing each layer was named layer_type? If we use the same serializer as above, we get this error:

Polymorphic serializer was not found for missing class discriminator (‘null’)

In other words, the Kotlin serializer is saying: “I don’t know which serializer to use, because I don’t have a way of telling these classes apart.”

The solution here would be to directly tell the serializer which field to use in order to differentiate between different classes. This is possible by using the annotation @JsonClassDiscriminator. In our case, it would look like this:

And the serialization works once again.

It’s worth noting that this is a problem we faced specifically because the serialization was platform agnostic. If the serialization was done by Kotlin initially, the Kotlin serializer would encode the default type field with the subclass name, and then use it to differentiate between different subtypes in polymorphism.

Transformation is Key

Let’s look at another case where a complex type written in the JSON needs to be represented differently in the object. To do this, we want to somehow transform the JsonElements before completing the deserialization. We can think of the JsonElements as the abstract building blocks of a JSON file. The transformation is like taking these building blocks and reorganizing them in the way we would like them to be.

A graph showing at what stage of the deserialization process we apply the transformation on the JSON elements.
The Kotlin serializer deserializes the JSON file to JSON elements. This is where we apply the transformation.

Let’s dive in by looking at an example. Taking our VideoProject example again, let’s add a field to the TextLayer representing the center of the layer. The JSON with this new field will look like this:

We can see that the center is defined as a JSON array. From the documentation, we are told that the first value is the x-coordinate and the second is the y-coordinate.

In Kotlin, we want to represent the center field as a data class called Point, because this is the more correct abstraction from a code point of view. This would look like:

However, the Point class as defined above would be serialized like this:

And not how it is defined in the JSON (as an array).

How can we convert between these two JSON elements? The solution is to use a class called JsonTransformingSerializer. This class allows you to do any transformations needed between different JsonElements, before the serialization or deserialization. For example, it allows you to change a JsonArray to JsonObject or vice versa — which is what we need for the Point object transformation here.

Specifically, we can override the functions transformDeserialize(element: JsonElement) and transformSerialize(element: JsonElement), which do exactly what we need. Inside these functions, we need to add the logic of the transformation. In our case, this means taking the elements of the array and mapping them to the right properties in Point to create a JsonObject.

In deserialization, this would look like:

In serialization, we need to do the opposite transformation, taking the values (while omitting the keys, which are x and y) in the JsonObject and transforming them back into a JsonArray. This would look like this:

Finally, we need to use this serializer in the center field in the TextLayer.

Great! We once again have the serialization working!

This interface can be useful in many other scenarios, such as omitting or manipulating certain values, or array wrapping/unwrapping.

Conclusion

I sincerely hope that you will always be able to use the default serializer for all your serialization needs. But I know that sometimes, as in the case of cross-platform JSON files, the default serialization is not quite good enough. In those tough cases, I hope you find these JSON serialization examples helpful.

For more information, I also recommend checking out the full Kotlin serialization guide (here) which is full of lots of examples and explanations.

Happy serialization!

Create magic with us

We’re always on the lookout for promising new talent. If you’re excited about developing groundbreaking new tools for creators, we want to hear from you. From writing code to researching new features, you’ll be surrounded by a supportive team who lives and breathes technology.

Sound like you? Apply here.

--

--

Lightricks
Lightricks Tech Blog

Learn more about how Lightricks is pushing the limits of technology to bridge the gap between imagination and creation.