I have a model in Kotlin of a simple library of Books and Borrowers where a Book is checked out if it has a Borrower. I use Arrow Option to encode the absence/presence of a Borrower:
data class Borrower(val name: Name, val maxBooks: MaxBooks)
data class Book(val title: String, val author: String, val borrower: Option<Borrower> = None)
I am having trouble serializing/deserializing these objects to/from JSON in Gson - specifically the representation of an Option<Borrower>
to a JSON null
within a Book:
[
{
"title": "Book100",
"author": "Author100",
"borrower": {
"name": "Borrower100",
"maxBooks": 100
}
},
{
"title": "Book200",
"author": "Author200",
"borrower": null
}
]
My deserialize code:
fun jsonStringToBooks(jsonString: String): List<Book> {
val gson = Gson()
return try {
gson.fromJson(jsonString, object : TypeToken<List<Book>>() {}.type)
} catch (e: Exception) {
emptyList()
}
}
I get an empty list. The nearly identical jsonStringToBorrowers
works fine.
Can someone point me in the right direction?
Would using a different JSON library like kotlinx.serialization
or Klaxon
be a better idea and how do they do the null <-> None
thing?
Thank you!
The issue is a bit hidden by the fact that you don't log the exception before returning an empty list. If you logged that exception you would have gotten this:
java.lang.RuntimeException: Failed to invoke private arrow.core.Option() with no args
This means that Gson doesn't know how to create an Option
class because it has no public empty constructor. Indeed, Option
is a sealed class (hence abstract) having 2 concrete children classes: Some
and None
. In order to get an instance of Option
you should use one of the factory methods, like Option.just(xxx)
or Option.empty()
among the others.
Now, in order to fix your code you need to tell Gson how to deserialize an Option
class. To do that, you need to register a type adapter to your gson
object.
A possible implementation is the following:
class OptionTypeAdapter<E>(private val adapter: TypeAdapter<E>) : TypeAdapter<Option<E>>() {
@Throws(IOException::class)
override fun write(out: JsonWriter, value: Option<E>) {
when (value) {
is Some -> adapter.write(out, value.t)
is None -> out.nullValue()
}
}
@Throws(IOException::class)
override fun read(input: JsonReader): Option<E> {
val peek = input.peek()
return if (peek != JsonToken.NULL) {
Option.just(adapter.read(input))
} else {
input.nextNull()
Option.empty()
}
}
companion object {
fun getFactory() = object : TypeAdapterFactory {
override fun <T> create(gson: Gson, type: TypeToken<T>): TypeAdapter<T>? {
val rawType = type.rawType as Class<*>
if (rawType != Option::class.java) {
return null
}
val parameterizedType = type.type as ParameterizedType
val actualType = parameterizedType.actualTypeArguments[0]
val adapter = gson.getAdapter(TypeToken.get(actualType))
return OptionTypeAdapter(adapter) as TypeAdapter<T>
}
}
}
}
You can use it in the following way:
fun main(args: Array<String>) {
val gson = GsonBuilder()
.registerTypeAdapterFactory(OptionTypeAdapter.getFactory())
.create()
val result: List<Book> = try {
gson.fromJson(json, TypeToken.getParameterized(List::class.java, Book::class.java).type)
} catch (e: Exception) {
e.printStackTrace()
emptyList()
}
println(result)
}
That code outputs:
[Book(title=Book100, author=Author100, borrower=Some(Borrower(name=Borrower100, maxBooks=100))), Book(title=Book200, author=Author200, borrower=None)]