Advanced kotlinx serialization

Peter Nagy
5 min readJan 11, 2021

Well I have written an article about problem of using GSON (it will instantiate an object with null value(s) however object have non-nullable fields):

I wrote the solution is JetBrains serialization library: kotlinx.serialization

Let's see how can we use this library and I want to show some special cases too.

We need to annotate every class with "@Serializable" annotation which we want to serialize.

@Serializable 
data class Project(val name: String, val language: String)

After it we can use it in serialization or deserialization.

val string = Json.encodeToString(data)
or
val obj = Json.decodeFromString<Project>(string)

It is easy, isn't it ? And what about if we want to serialize a 3rd party class where we can not annotate it ?

In this case we need to write a custom serializer. Like this:

object DateAsLongSerializer : KSerializer<Date> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.LONG)
override fun serialize(encoder: Encoder, value: Date) = encoder.encodeLong(value.time)
override fun deserialize(decoder: Decoder): Date = Date(decoder.decodeLong())
}

It will serialize Date objects like a Long number. Ok, ok but how will know the lib if there is a Long number is it Date or Long ? So how can we bind the serializer with the proper field ? We can annotate the fields with the custom serializer.

@Serializable          
class ProgrammingLanguage(
val name: String,
@Serializable(with = DateAsLongSerializer::class)
val stableReleaseDate: Date
)

It is easy, but let's see another example. There is OkHttp's Cookie class and we want to serialize them (in a Persistant Cookie Jar). We can think it can work by default… what is the problem with it ? Cookie has private constructor. So the kotlinx serialization library will be not able to call Cookie() constructor. We need to write how can the custom serializer instantiate the class.

@ExperimentalSerializationApi
@Serializer(forClass = Cookie::class)
object CookieSerializer {

@InternalSerializationApi
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Cookie") {
element("domain", PrimitiveSerialDescriptor("domain", PrimitiveKind.STRING))
element("expiresAt", PrimitiveSerialDescriptor("expiresAt", PrimitiveKind.LONG))
element("hostOnly", PrimitiveSerialDescriptor("hostOnly", PrimitiveKind.BOOLEAN))
element("httpOnly", PrimitiveSerialDescriptor("httpOnly", PrimitiveKind.BOOLEAN))
element("name", PrimitiveSerialDescriptor("name", PrimitiveKind.STRING))
element("path", PrimitiveSerialDescriptor("path", PrimitiveKind.STRING))
element("persistent", PrimitiveSerialDescriptor("persistent", PrimitiveKind.BOOLEAN))
element("secure", PrimitiveSerialDescriptor("secure", PrimitiveKind.BOOLEAN))
element("value", PrimitiveSerialDescriptor("value", PrimitiveKind.STRING))
}

@InternalSerializationApi
override fun serialize(encoder: Encoder, value: Cookie) {
val structure = encoder.beginStructure(descriptor)
structure.encodeStringElement(descriptor, 0, value.domain)
structure.encodeLongElement(descriptor, 1, value.expiresAt)
structure.encodeBooleanElement(descriptor, 2, value.hostOnly)
structure.encodeBooleanElement(descriptor, 3, value.httpOnly)
structure.encodeStringElement(descriptor, 4, value.name)
structure.encodeStringElement(descriptor, 5, value.path)
structure.encodeBooleanElement(descriptor, 6, value.persistent)
structure.encodeBooleanElement(descriptor, 7, value.secure)
structure.encodeStringElement(descriptor, 8, value.value)
structure.endStructure(descriptor)
}

@InternalSerializationApi
override fun deserialize(decoder: Decoder): Cookie {
return decoder.decodeStructure(descriptor) {
var domain = ""
var expiresAt: Long? = null
var hostOnly = false
var httpOnly = false
var name = ""
var path: String? = null
var persistent = false
var secure = false
var value = ""
while (true) {
when (val index = decodeElementIndex(descriptor)) {
0 -> domain = decodeStringElement(descriptor, 0)
1 -> expiresAt = decodeLongElement(descriptor, 1)
2 -> hostOnly = decodeBooleanElement(descriptor, 2)
3 -> httpOnly = decodeBooleanElement(descriptor, 3)
4 -> name = decodeStringElement(descriptor, 4)
5 -> path = decodeStringElement(descriptor, 5)
6 -> persistent = decodeBooleanElement(descriptor, 6)
7 -> secure = decodeBooleanElement(descriptor, 7)
8 -> value = decodeStringElement(descriptor, 8)
CompositeDecoder.DECODE_DONE -> break
else -> error("Unexpected index: $index")
}
}
val cookieBuilder = Cookie.Builder().name(name).value(value)
if (expiresAt != null) {
cookieBuilder.expiresAt(expiresAt)
}
if (hostOnly) {
cookieBuilder.hostOnlyDomain(domain)
} else {
cookieBuilder.domain(domain)
}
if (httpOnly) {
cookieBuilder.httpOnly()
}
if (secure) {
cookieBuilder.secure()
}
if (path != null) {
cookieBuilder.path(path)
}
cookieBuilder.build()
}
}
}

In this case when we want to serialize or deserialize a cookie we need to write the custom serializer too:

val serializedCookie = Json.encodeToString(CookieSerializer, cookie)

Ok, and let's see another example with BigDecimal

@ExperimentalSerializationApi
@Serializer(forClass = BigDecimal::class)
object BigDecimalSerializer {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BigDecimal", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: BigDecimal) {
encoder.encodeString(value.toString())
}

override fun deserialize(decoder: Decoder): BigDecimal {
return BigDecimal(decoder.decodeString())
}
}
@Serializable
data class MyCustomData(@Serializable(with = BigDecimalSerializer::class) val price: BigDecimal, val name: String)
val myData = MyCustomData(BigDecimal("12.1"), "foo")val result = Json.encodeToString(myData){"price":"12.1","name":"abc"}

The main different is: at the case of Cookie we will create a class -> { "cookie" : {…} }
and at the case of BigDecimal it will be a json element (a string value) instead of class.

Sometimes it could happen we get a Json from backend however we do not want to parse the whole json into an object. (We will use only a part of the response like an object and there is a part what we want to handle like a plain json, like send it to the 3rd party lib or other component of our application.)
For example we got a response which will contain a config part (user settings, or experiment configuration).

{"appData":{"version":1,"data":"foo"},"config":{"configName":"foo","version":1,"value":"1.2345678912"}}

If we want to transfer a Json (to other part of our application) then we can use JsonObject like an object.

@Serializable
data class AppData(val version: Int, val data: String)
@Serializable
data class ResponseImpl(val appData: AppData, val config: JsonObject)

ToString og the ResponseImpl

ResponseImpl(appData=AppData(version=1, data=foo), config={"configName":"foo","version":1,"value":"1.2345678912"})

Ok, it is almost done, however it means we will store and pass a serializer dependent object and when we replace our serializer then we need to change the object. (it is wrong pattern) It would be better to use an independent object like a String. (Json is a String representation of the data, then we can store it like a String)

@Serializable
data class AppData(val version: Int, val data: String)
@Serializable
data class ConfigImpl(val configJson: String)
@Serializable
data class ResponseImpl(val appData: AppData, @Serializable(with = JsonAsStringSerializer::class) val config: ConfigImpl)
object JsonAsStringSerializer : KSerializer<ConfigImpl> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("config") {
element<String>("configJson")
}

override fun deserialize(decoder: Decoder): ConfigImpl {
val jsonDecoder = decoder as? JsonDecoder ?: error("Can be deserialized only by JSON")
val json = jsonDecoder.decodeJsonElement().jsonObject
return ConfigImpl(json.toString())
}

override fun serialize(encoder: Encoder, value: ConfigImpl) {
val jsonEncoder = encoder as? JsonEncoder ?: error("Can be serialized only by JSON")
val jsonDocument = jsonEncoder.json.parseToJsonElement(value.configJson).jsonObject
encoder
.encodeSerializableValue(JsonObject.serializer(), jsonDocument)
}
}

Now when we parse the original response it will contain a ConfigImpl which have a string field. (it is a plain json).

Above in the Json we have used String representation of floating-point number. Let's replace it:

{"appData":{"version":1,"data":"foo"},"config":{"configName":"foo","version":1,"value":48.321659646260812}}

We can think it does not matter because we will parse it like a JsonObject. Ok, let's see it.

val jsonString = "{\"appData\":{\"version\":1,\"data\":\"foo\"},\"config\":{\"configName\":\"foo\",\"version\":1,\"value\":48.321659646260812}}"
val obj = Json.decodeFromString<ResponseImpl>(jsonString)
val jsonString2 = Json.encodeToJsonElement(obj)

and jsonString2 is:

{"appData":{"version":1,"data":"foo"},"config":{"configName":"foo","version":1,"value":48.32165964626081}}

It seems when we encode — decode same object we can loose some data. Why ?
Because the serializer will parse the data -> there is floating-point value and it will be cast to Double. And Double is not enough precise to store the original number.

Unfortunately we are not able to override this default behaviour of the lib. Because the lib will do it by default we will lose data (precision).

  • When we do not want to parse whole object then we might loose some data
  • Or we must parse the whole object. (Create data class(es) redundant and somewhere we must convert data object to String or Json before we send it to the other part of the application (3rd party lib).

--

--