Search code examples
kotlingsonarrow-kt

Kotlin + Arrow + Gson = None?


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!


Solution

  • 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)]