1. Overview

When deserializing a JSON object, a data class establishes a contract between our code and the content we expect to consume. The data class defines mandatory and optional typed properties, limiting space for ambiguity. Such binding is possible if we’re in control of the JSON content, say because we’re creating it or sharing a schema with its authors.

With these conditions in place, a data class usually is the best strategy for handling deserialization, and all the mainstream Kotlin JSON parsers out there have their means to support this approach.

However, several factors can prevent this level of control. In these cases, JSON content always appears dynamic and unpredictable to consumers, and we cannot rely on a data class for handling deserialization. What we need instead is a transparent object model capable of accommodating a variable number of heterogeneous properties.

In this tutorial, we’ll explore possibilities to deserialize JSON when we cannot use a data class. We can use the Kotlin Map<String, Any?> generic map definition for dynamic content deserialization or parser-specific JSON representations. We’ll address both strategies within the context of the Jackson, Gson, Moshi, and Kotlinx serialization frameworks.

2. Jackson Dynamic Content Deserialization

Let’s see how Jackson can be used in Kotlin to deserialize dynamic JSON content.

2.1. Maven Configuration

Let’s prepare our environment with minimal Maven dependencies for driving the Jackson library through Kotlin:

<dependency> 
    <groupId>com.fasterxml.jackson.module</groupId>
    <artifactId>jackson-module-kotlin</artifactId>
    <version>2.14.2</version> 
</dependency>

The latest version of the Jackson Kotlin module can be found on Maven Central

2.2. Library Usage

First, we create an ObjectMapper instance endowed with Kotlin module support:

val mapper = jacksonObjectMapper()

Let jsonString be the String variable holding the content we want to deserialize. We can parse a JSON object directly to a generic Map<String, Any?> in this way:

val model: Map<String, Any?> = mapper.readValue(jsonString)

We all know Jackson library also provides low-level JSON element representations, all inheriting from the base JsonNode abstract class. If we prefer to manipulate these objects instead of navigating a generic map, we can deserialize to this hierarchy instead:

val model: JsonNode = mapper.readTree(jsonString)

The resulting JsonNode instance is maybe more effective than a Kotlin generic map when it comes to browsing the deserialized document. Still, it introduces tighter coupling of our code with the Jackson library. It is up to us to decide if such a coupling is acceptable for the architecture of our program.

3. Gson Dynamic Content Deserialization

Let’s see how also Gson can be used to deserialize JSON content without any data class.

3.1. Maven Configuration

To use Gson, we have to include the following dependency in our pom.xml:

<dependency>
    <groupId>com.google.code.gson</groupId>
    <artifactId>gson</artifactId>
    <version>2.10.1</version>
</dependency>

The latest version of Gson can be found on Maven Central

3.2. Library Usage

We have to prepare first the needed Gson instance, to be used for retrieving the Adapter for a generic map:

val gson = Gson()

val mapAdapter = gson.getAdapter(object: TypeToken<Map<String, Any?>>() {})

With jsonString as the String variable holding the JSON content, we can parse directly to a generic Map<String, Any?> in this way:

val model: Map<String, Any?> = mapAdapter.fromJson(jsonString)

The Gson library also provides low-level JSON element representations, whose entry point is the abstract JsonElement class. We can target this class during deserialization via:

val jsonElement = JsonParser.parseString(jsonString)

The JsonElement provides a convenience method for casting the element instance to a concrete JsonObject, as shown below:

val jsonObject = jsonElement.asJsonObject

4. Moshi Dynamic Content Deserialization

We can also use Moshi to deserialize JSON content to a transparent object model. In this case, library usage is very similar to Gson.

4.1. Maven Configuration

To use Moshi, we have to include the following dependencies in our pom.xml:

<dependency>
    <groupId>com.squareup.moshi</groupId>
    <artifactId>moshi</artifactId>
    <version>1.14.0</version>
</dependency>
<dependency>
    <groupId>com.squareup.moshi</groupId>
    <artifactId>moshi-kotlin</artifactId>
    <version>1.14.0</version>
</dependency>
<dependency>
    <groupId>com.squareup.moshi</groupId>
    <artifactId>moshi-adapters</artifactId>
    <version>1.14.0</version>
</dependency>

The latest version of Moshi can be found on Maven Central

4.2. Library Usage

We have to prepare first the Moshi instance, to be used for retrieving the Adapter for a generic map:

val moshi = Moshi.Builder().build()

val mapAdapter = moshi.adapter<Map<String, Any?>>().serializeNulls()

Since Moshi is fully aware of the Kotlin type system, notice how we need to chain the serializeNulls method to the retrieved adapter to allow for null values deserialization.

Again, with our jsonString variable holding the string JSON content to process, we can parse directly to a generic Map<String, Any?> in this way:

val model: Map<String, Any?>  = mapAdapter.fromJson(input) ?: mapOf()

The Moshi library does not provide access to its internal JSON elements representations. Therefore, the generic Map<String, Any?> type is the most transparent representation when deserializing content in Moshi without a dedicated data class.

5. Kotlinx Dynamic Content Deserialization

Kotlinx Serialization is Jetbrains’ official multiplatform serialization solution for the Kotlin ecosystem. It is heavily based on code generation to favour reflectionless data binding. We will show below that mapping content to a generic map via Kotlinx is still possible, despite requiring a little more effort.

5.1. Maven Configuration

We have to add the following dependency to enable Kotlinx JSON serialization in our project:

<dependency>
    <groupId>org.jetbrains.kotlinx</groupId>
    <artifactId>kotlinx-serialization-json</artifactId>
    <version>1.5.0</version>
</dependency>

The latest version of Kotlinx serialization for JSON can be found on Maven Central

5.2. Library Usage

Kotlinx Serialization allows JSON content deserialization to a JsonObject class via a single liner. If jsonString holds the JSON string content, that line becomes:

val jsonObject: JsonObject = Json.decodeFromString(jsonString)

Things get a little more problematic when targeting our Map<String, Any?> transparent model. The logic for converting the resulting JsonObject to Map<String, Any?> could be placed in the application, but responsibility for this conversion should really be placed within the serialization framework machinery. We can achieve this result by defining a dedicated KSerializer<Map<String, Any?>:

object KotlinxGenericMapSerializer : KSerializer<Map<String, Any?>> {

    override val descriptor: SerialDescriptor = buildClassSerialDescriptor("GenericMap")

    override fun serialize(encoder: Encoder, value: Map<String, Any?>) {
        val jsonObject = JsonObject(value.mapValues { it.value.toJsonElement()})
        val jsonObjectSerializer = encoder.serializersModule.serializer<JsonObject>()
        jsonObjectSerializer.serialize(encoder, jsonObject)
    }

    override fun deserialize(decoder: Decoder): Map<String, Any?> {
        val jsonDecoder = decoder as? JsonDecoder ?: throw SerializationException("Can only deserialize Json content to generic Map")
        val root = jsonDecoder.decodeJsonElement()
        return if (root is JsonObject) root.toMap() else throw SerializationException("Cannot deserialize Json content to generic Map")
    }

    private fun Any?.toJsonElement(): JsonElement = when(this) {
        null -> JsonNull
        is String -> JsonPrimitive(this)
        is Number -> JsonPrimitive(this)
        is Boolean -> JsonPrimitive(this)
        is Map<*, *> -> toJsonObject()
        is Iterable<*> -> toJsonArray()
        else -> throw SerializationException("Cannot serialize value type $this")
    }

    private fun Map<*,*>.toJsonObject(): JsonObject = JsonObject(this.entries.associate { it.key.toString() to it.value.toJsonElement() })

    private fun Iterable<*>.toJsonArray(): JsonArray = JsonArray(this.map { it.toJsonElement() })

    private fun JsonElement.toAnyNullableValue(): Any? = when (this) {
        is JsonPrimitive -> toScalarOrNull()
        is JsonObject -> toMap()
        is JsonArray -> toList()
    }

    private fun JsonObject.toMap(): Map<String, Any?> = entries.associate {
        when (val jsonElement = it.value) {
            is JsonPrimitive -> it.key to jsonElement.toScalarOrNull()
            is JsonObject -> it.key to jsonElement.toMap()
            is JsonArray -> it.key to jsonElement.toAnyNullableValueList()
        }
    }

    private fun JsonPrimitive.toScalarOrNull(): Any? = when {
        this is JsonNull -> null
        this.isString -> this.content
        else -> listOfNotNull(booleanOrNull, longOrNull, doubleOrNull).firstOrNull()
    }

    private fun JsonArray.toAnyNullableValueList(): List<Any?> = this.map {
        it.toAnyNullableValue()
    }
}

The deserialize method starts the JsonObject conversion to a generic map. The JsonObject.toMap (extension) function walks the JsonObject tree, looking first for JsonPrimitive instances to unwrap to their corresponding native data type. When the function encounters JsonObject or JsonArray instances, it triggers recursion to explore the remaining parts of the nested data structure. We can see the JsonPrimitive.toScalarOrNull function holds the mapping logic from JSON literals to Kotlin types, and we could think about customizing it if we need to fit specific mapping requirements.

The class also provides the dual serialize method, part of the KSerializer contract, that we can use for converting a generic map instance back to its JsonObject counterpart should we need to serialize it.

Since the KotlinxGenericMapSerializer implements the KSerializer interface, we can integrate all of this conversion logic within the Kotlinx framework itself. The jsonString deserialization to a generic map finally becomes a single liner:

val model: Map<String, Any?> = Json.decodeFromString(KotlinxGenericMapSerializer, jsonString)

6. Conclusion

In this tutorial, we have shown how to bypass a data class when deserializing JSON content in Kotlin. We have demonstrated that Jackson, Gson, Moshi, and Kotlinx all support a transparent model for deserialization.

Dynamic JSON content parsing is possible, but it can challenge the architecture of our program. The generic Map<String, Any?> model can negatively impact code readability. For Kotlin, null safety will kick in for any meaningful operation on the map (a problem we could possibly address via Kotlin map delegates).

Conversely, framework-specific representations allow for more straightforward model navigation, with the risk of coupling most of the program code to a deserialization library.

The implementation of the provided examples is over on GitHub.

Comments are closed on this article!