Moshi is one of the most popular JSON parsing libraries on Android that allows us to convert a JSON object into a data class and vice versa. It plays well with Retrofit, one of the most popular networking libraries, and since server responses are mostly represented in a JSON format, Moshi will save us from writing boilerplate code when parsing them on the client.
But for DRY-loving Android developers Moshi has a fatal flaw; enumerations. Out of the box, Moshi will require custom parsers for every enum in your API or it will require you to represent enums as Strings which no backend database will ever do.
Can we avoid repeating boilerplate code whilst still being able to parse enums without representing them as Strings? Of course we can (it’s software!), but the answer may surprise you.
Why Android developers love Moshi
Because all you need is an annotation. We have the following JSON representation of a Person
which can be automatically converted into a data class, simply by adding the @JsonClass(generateAdapter = true)
annotation. This annotation will generate an adapter at compile-time that will do the parsing for you.
{ | |
"name": "John", | |
"age": 28 | |
} |
JSON representation of a Person
@JsonClass(generateAdapter = true) | |
data class Person( | |
val name: String, | |
val age: Int | |
) |
For more complex data types, Moshi gives us the flexibility of writing our own custom adapters, essentially supporting all kinds of modeling that we wish to achieve.
Parsing enums with Moshi
Let’s add an additional property in our Person
data class, which is Role
. That would be an enum class with two possible values for now, Admin
or Moderator
.
enum class Role { | |
Admin, | |
Moderator | |
} | |
@JsonClass(generateAdapter = true) | |
data class Person( | |
val name: String, | |
val role: Role | |
) |
Now, what type of value do you expect in the JSON representation to map it into a Role
? Is it a number or a String?
Moshi supports enum parsing by using the EnumJsonAdapter but it comes with a catch. The value in the JSON needs to be a String matching the name of the enum constant on the client. This obviously makes the code error-prone and harder to refactor, since by simply changing the name of an enum constant you will break the parsing.
fun createMoshi(): Moshi { | |
return Moshi.Builder() | |
.add(EnumJsonAdapter.create(Role::class.java) | |
.build() | |
} |
Using Moshi’s EnumJsonAdapter
to parse a Role
{ | |
"name": "John", | |
"role": "Admin" | |
} |
What if use numbers to represent the values instead?
Since representing the values as Strings is error-prone, what if we use plain integers that the client will parse and map them to the respective enum constant?
{ | |
"name": "John", | |
"role": 1 | |
} |
This makes our API less verbose and our client code easier to maintain since we no longer allow ourselves to break the parsing by simply renaming an enum constant.
However, now we can no longer use the EnumJsonAdapter that is provided by Moshi since the values are not represented by Strings. We will have to write a custom adapter that will map the integer into an enum constant.
First, let’s add a value
property in our Role
enum class that will match the one that we have defined in our JSON representation:
enum class Role(val value: Int) { | |
Admin(1), | |
Moderator(2); | |
companion object { | |
fun fromValueOrNull(value: Int): Role? { | |
return values().firstOrNull { it.value == value } | |
} | |
} | |
} |
As we can see in the snippet above, an Admin
is now associated with a value of 1
while a Moderator
is associated with a value of 2
. The fromValueOrNull
function will take the value and map it into an enum constant.
Let’s now write a custom adapter that will do the fromJson
and toJson
mapping:
class RoleEnumJsonAdapter : JsonAdapter<Role>() { | |
@FromJson | |
override fun fromJson(reader: JsonReader): Role? { | |
return if (reader.peek() != JsonReader.Token.NULL) { | |
Role.fromValueOrNull(reader.nextInt()) | |
} else { | |
reader.nextNull() | |
} | |
} | |
@ToJson | |
override fun toJson(writer: JsonWriter, value: Role?) { | |
writer.value(value?.value) | |
} | |
} |
We now managed to define enum classes as plain numbers in our server responses, rather than using Strings. But we did that at the cost of writing a custom adapter. What if we have dozens of enums with an associated value? Do we need to write all of that boilerplate code each time? Well that’s where generics come into play.
Creating a generic enum JSON adapter
First of all, we agree that we need to associate our enums with a number. That means that we’ll need to define an interface which has to be implemented by our enums:
interface IEnumValue { | |
val value: Int | |
} | |
enum class Role(override val value: Int) : IEnumValue { | |
Admin(1), | |
Moderator(2); | |
companion object { | |
fun fromValueOrNull(value: Int): Role? { | |
return values().firstOrNull { it.value == value } | |
} | |
} | |
} |
To avoid now defining a fromValueOrNull
function on every enum, we’ll take advantage of the interface we just created and we’ll create a GenericEnumFactory
with a fromValueOrNull
function that will take an integer and the type T
of the enum and will do the mapping to return us the enum constant. The type parameter T
will have of course to implement the IEnumValue
interface:
enum class Role(override val value: Int) : IEnumValue { | |
Admin(1), | |
Moderator(2) | |
} | |
object GenericEnumFactory { | |
inline fun <reified T> fromValueOrNull(rawValue: Int?): T? where T : Enum<T>, T : IEnumValue { | |
return enumValues<T>().firstOrNull { it.value == rawValue } | |
} | |
} | |
class RoleEnumJsonAdapter : JsonAdapter<Role>() { | |
@FromJson | |
override fun fromJson(reader: JsonReader): Role? { | |
return if (reader.peek() != JsonReader.Token.NULL) { | |
GenericEnumFactory.fromValueOrNull(reader.nextInt()) | |
} else { | |
reader.nextNull() | |
} | |
} | |
@ToJson | |
override fun toJson(writer: JsonWriter, value: Role?) { | |
writer.value(value?.value) | |
} | |
} |
We just managed to remove some boilerplate code from our enums, but we still have to define a new JsonAdapter
per enum. Could we somehow define only one adapter and reuse it across all our enums?
Job Offers
The magic of reified type parameters
If we want to create a generic adapter, we will need access to the type T
of the enum so that we can access the enum constants and their value
that the IEnumValue
interface provides. But we know that the type is erased at runtime and only available at compile time. That’s exactly where the reified type parameters come in — they will allow us to access a type passed as a parameter as if it was a normal class.
However at the time of writing this post, reified type parameters are not supported in class level in Kotlin, therefore any attempt to create a generic adapter like the following would fail:
class GenericEnumJsonAdapter<T> : JsonAdapter<T>() { | |
@FromJson | |
override fun fromJson(reader: JsonReader): T? { | |
// Cannot access enumValues<T>() | |
} | |
@ToJson | |
override fun toJson(writer: JsonWriter, value: T?) { | |
// ... | |
} | |
} |
There’s another trick we can do though. We know that reified type parameters are supported in inline functions, so instead of defining a generic class, what if we define a generic function that will return the generic JsonAdapter<T>
? And that will be a perfectly valid solution!
inline fun <reified T> createEnumJsonAdapter(): JsonAdapter<T> where T : Enum<T>, T : IEnumValue { | |
return object : JsonAdapter<T>() { | |
@FromJson | |
override fun fromJson(reader: JsonReader): T? { | |
return if (reader.peek() != JsonReader.Token.NULL) { | |
enumValues<T>().firstOrNull { it.value == reader.nextInt() } | |
} else { | |
reader.nextNull() | |
} | |
} | |
@ToJson | |
override fun toJson(writer: JsonWriter, value: T?) { | |
writer.value(value?.value) | |
} | |
} | |
} |
There we have a generic function that will auto-generate for us the enum adapter to do the fromJson
and toJson
mapping, and that supports the integer representation of our enums in the server responses!
Note:
firstOrNull as hinted by its name can return
null if the integer
value we provide cannot be mapped into an enum constant. This means that the enums in the data classes that model our server responses have to be nullable. We have the option though to define a default value, for example by returning
enumValues<T>().first() if
firstOrNull returns
null.
Usage of this function is pretty simple, we just need to add the adapters in our Moshi.Builder
definition, similarly as we would do for any other custom Moshi adapter:
fun createMoshi(): Moshi { | |
return Moshi.Builder() | |
.add(createEnumJsonAdapter<Role>()) | |
.add(createEnumJsonAdapter<Category>()) | |
.add(createEnumJsonAdapter<AnotherEnum>()) | |
// ... | |
.build() | |
} |
This approach allows us to represent enums as integers in the server responses without us having to write and maintain a separate adapter for each of our enum classes. At the same time, the code becomes easier to maintain and refactor, and more resilient to bugs.
About the authors
Lucas Cavalcante and Stelios Frantzeskakis are developers for Perry Street Software, publishers of the LGBTQ+ dating apps SCRUFF and Jack’d, with more than 30M members worldwide.