1. Overview

When working with data classes in Kotlin, there’s sometimes a need to convert a data class object to a Map. There are several built-in or 3rd party libraries that can help us with this. These include Kotlin Reflection, Jackson, Gson, and Kotlin Serialization.

In this article, we’ll discuss each of these ways. Then, we’ll take a closer look at the differences.

2. Data Class

Before we get into different implementations, let’s define an example data class.

enum class ProjectType {
    APPLICATION, CONSOLE, WEB
}

data class ProjectRepository(val url: String)

data class Project(
    val name: String,
    val type: ProjectType,
    val createdDate: Date,
    val repository: ProjectRepository,
    val deleted: Boolean = false,
    val owner: String?
) {
    var description: String? = null
}

Project contains several typical properties we might find in a data class:

  • primitive – name
  • enum – type
  • date – createdDate
  • nested data class – repository
  • property with default value – deleted
  • nullable property – owner
  • property declared in the class body – description

We’ll create an instance of Project:

val PROJECT = Project(
    name = "test1",
    type = ProjectType.APPLICATION,
    createdDate = Date(1000),
    repository = ProjectRepository(url = "http://test.baeldung.com/test1"),
    owner = null
).apply {
    description = "a new project"
}

When converting the PROJECT data object to a map, our solution should handle all these properties properly.

3. Kotlin Reflection

Kotlin Reflection is a straightforward library to try first.

3.1. Maven Dependency

First of all, let’s include the kotlin-reflect dependency in our pom.xml:

<dependency>
    <groupId>org.jetbrains.kotlin</groupId>        
    <artifactId>kotlin-reflect</artifactId>
    <version>1.6.10</version>
</dependency>

3.2. Conversion with All Properties

Using reflection, we can build a recursive method to check the properties of an object. This will add each property to a map:

fun <T : Any> toMap(obj: T): Map<String, Any?> {
    return (obj::class as KClass<T>).memberProperties.associate { prop ->
        prop.name to prop.get(obj)?.let { value ->
            if (value::class.isData) {
                toMap(value)
            } else {
                value
            }
        }
    }
}

Next, let’s can verify the converted map:

assertEquals(
    mapOf(
        "name" to "test1",
        "type" to ProjectType.APPLICATION,
        "createdDate" to Date(1000),
        "repository" to mapOf(
            "url" to "http://test.baeldung.com/test1"
        ),
        "deleted" to false,
        "owner" to null,
        "description" to "a new project"
    ),
    toMap(project)
)

3.3. Conversion with Only Properties in Primary Constructor

As we know, the default toString function of a data class will convert a data object to a string. However, this string contains only the properties from the primary constructor:

assertFalse(project.toString().contains(Project::description.name))

If we want to match this behavior in our toMap function, we could add a filtering step:

fun <T : Any> toMapWithOnlyPrimaryConstructorProperties(obj: T): Map<String, Any?> {
    val kClass = obj::class as KClass<T>
    val primaryConstructorPropertyNames = kClass.primaryConstructor?.parameters?.map { it.name } ?: run {
        return toMap(obj)
    }
    return kClass.memberProperties.mapNotNull { prop ->
        prop.name.takeIf { it in primaryConstructorPropertyNames }?.let {
            it to prop.get(obj)?.let { value ->
                if (value::class.isData) {
                    toMap(value)
                } else {
                    value
                }
            }
        }
    }.toMap()
}

In this function, if the class does not have a primary constructor, we’ll fall back to the original toMap function.

Let’s verify the result:

assertEquals(
    mapOf(
        "name" to "test1",
        "type" to ProjectType.APPLICATION,
        "createdDate" to Date(1000),
        "repository" to mapOf(
            "url" to "http://test.baeldung.com/test1"
        ),
        "deleted" to false,
        "owner" to null
    ),
    toMapWithOnlyPrimaryConstructorProperties(project)
)

3.4. Limitations

This approach has some limitations:

  • Doesn’t support date property serialization to string with a specific format
  • We’re not able to exclude specific properties from the result
  • There’s no support for reversing the conversion back to a data object

Therefore, if we need these capabilities, maybe we should use serialization libraries instead.

4. Jackson

Next, let’s try the Jackson library.

4.1. Maven Dependency

As usual, we need to include the jackson-module-kotlin dependency in our pom.xml:

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

4.2. Conversion with All Properties

First of all, let’s create an ObjectMapper instance with KotlinModule registered:

val DEFAULT_JACKSON_MAPPER = ObjectMapper().registerModule(KotlinModule())

Jackson will convert all values to primitive types. By default, Date will be converted to Long:

assertEquals(
    mapOf(
        "name" to "test1",
        "type" to ProjectType.APPLICATION.name,
        "createdDate" to 1000L,
        "repository" to mapOf(
            "url" to "http://test.baeldung.com/test1"
        ),
        "deleted" to false,
        "owner" to null,
        "description" to "a new project"
    ), DEFAULT_JACKSON_MAPPER.convertValue(PROJECT, Map::class.java)
)

However, if we can disable the default date format and set our own:

val DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss")

val JACKSON_MAPPER_WITH_DATE_FORMAT = ObjectMapper().registerModule(KotlinModule()).apply {
    disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
    dateFormat = DATE_FORMAT
}

Let’s verify the result:

val expected = mapOf(
    "name" to "test1",
    "type" to ProjectType.APPLICATION.name,
    "createdDate" to DATE_FORMAT.format(PROJECT.createdDate),
    "repository" to mapOf(
        "url" to "http://test.baeldung.com/test1"
    ),
    "deleted" to false,
    "owner" to null,
    "description" to "a new project"
)
assertEquals(expected, JACKSON_MAPPER_WITH_DATE_FORMAT.convertValue(PROJECT, Map::class.java))

We should note that Jackson has many other useful features. These include date serialization, ignore null fields, ignore properties, etc.

4.3. Conversion from Map to the Data Object

Jackson’s ObjectMapper can convert a map back to a data object for us:

assertEquals(PROJECT, JACKSON_MAPPER_WITH_DATE_FORMAT.convertValue(expected, Project::class.java))

Here, if any non-nullable property is missing from the map, Jackson throws an IllegalArgumentException:

val mapWithoutCreatedDate = mapOf(
    "name" to "test1",
    "type" to ProjectType.APPLICATION.name,
    "repository" to mapOf(
        "url" to "http://test.baeldung.com/test1"
    ),
    "deleted" to false,
    "owner" to null,
    "description" to "a new project"
)
assertThrows<IllegalArgumentException> { DEFAULT_JACKSON_MAPPER.convertValue(mapWithoutCreatedDate, Project::class.java) }

4.4. Limitations

Jackson is a JSON-oriented library. Therefore, it tries to convert all values to primitive types. So, it may not fit scenarios when we want to keep the property object as it is. This is because JSON doesn’t have primitive types for things like Date.

5. Gson

Gson is another library that can be used to convert objects into their map representation.

5.1. Maven Dependency

First, we need to include the gson dependency in our pom.xml:

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

5.2. Conversion with All Properties

Similarly, let’s create a Gson instance with its builder:

val GSON_MAPPER = GsonBuilder().serializeNulls().setDateFormat(DATE_FORMAT).create()

Using this builder, we’ve set Gson to serialize properties with null values and have specified a date format.

Let’s verify the result:

assertEquals(
    expected,
    GSON_MAPPER.fromJson(GSON_MAPPER.toJson(PROJECT), Map::class.java)
)

Gson also supports excluding properties from serialization.

5.3. Conversion from Map to the Data Object

We could use the same mapper to convert back to our data class type:

assertEquals(
    PROJECT,
    GSON_MAPPER.fromJson(GSON_MAPPER.toJson(expected), Project::class.java)
)

5.4. Non-Nullable Property

Unlike Jackson, Gson won’t complain if any non-nullable property is missing:

val newProject = GSON_MAPPER.fromJson(GSON_MAPPER.toJson(mapWithoutCreatedDate), Project::class.java)
assertNull(newProject.createdDate)

This could cause unexpected exceptions if we don’t handle it.

One way to fix this is to define a customized TypeAdapterFactory. 

class KotlinTypeAdapterFactory : TypeAdapterFactory {
    override fun <T : Any> create(gson: Gson, type: TypeToken<T>): TypeAdapter<T>? {
        val delegate = gson.getDelegateAdapter(this, type)
        if (type.rawType.declaredAnnotations.none { it.annotationClass == Metadata::class }) {
            return null
        }
        return KotlinTypeAdaptor(delegate, type)
    }
}

First, the KotlinTypeAdapterFactory will check if the target class is Kotlin class has *kotlin.*Metadata annotation. Next, it will return a KotlinTypeAdapter for Kotlin classes:

class KotlinTypeAdaptor<T>(private val delegate: TypeAdapter<T>, private val type: TypeToken<T>) : TypeAdapter<T>() {
    override fun write(out: JsonWriter, value: T?) = delegate.write(out, value)

    override fun read(input: JsonReader): T? {
        return delegate.read(input)?.apply {
            Reflection.createKotlinClass(type.rawType).memberProperties.forEach {
                if (!it.returnType.isMarkedNullable && it.get(this) == null) {
                    throw IllegalArgumentException("Value of non-nullable property [${it.name}] cannot be null")
                }
            }
        }
    }
}

Before returning the result from the delegate, the KotlinTypeAdaptor will guarantee all non-nullable properties have proper values*.* We’re using kotlin-reflect here, so we need the dependency for it from earlier.

Let’s verify the result. First, let’s create a mapper with KotlinTypeAdapterFactory registered:

val KOTLIN_GSON_MAPPER = GsonBuilder()
  .serializeNulls()
  .setDateFormat(DATE_FORMAT)
  .registerTypeAdapterFactory(KotlinTypeAdapterFactory())
  .create()

Then let’s try to convert a map without the createdDate property:

val exception = assertThrows<IllegalArgumentException> {
    KOTLIN_GSON_MAPPER.fromJson(KOTLIN_GSON_MAPPER.toJson(mapWithoutCreatedDate), Project::class.java)
}
assertEquals(
    "Value of non-nullable property [${Project::createdDate.name}] cannot be null",
    exception.message
)

As we can see, it throws an exception with the expected message.

5.5. Limitations

Similar to Jackson, Gson is also a JSON-oriented library and tries to convert all values to primitive types.

6. Kotlin Serialization

Kotlin Serializationis a data serialization framework. It converts trees of objects to strings and back. It’s a compiler plugin bundled with the Kotlin compiler distribution. In addition, it fully supports and enforces the Kotlin type system.

6.1. Maven Dependency

First, we need to add the serialization plugin to the Kotlin compiler:

<plugin>
    <groupId>org.jetbrains.kotlin</groupId>
    <artifactId>kotlin-maven-plugin</artifactId>
    <version>1.6.10</version>
    <executions>
        <execution>
            <id>compile</id>
            <phase>compile</phase>
            <goals>
                <goal>compile</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <compilerPlugins>
            <plugin>kotlinx-serialization</plugin>
        </compilerPlugins>
    </configuration>
    <dependencies>
        <dependency>
            <groupId>org.jetbrains.kotlin</groupId>
            <artifactId>kotlin-maven-serialization</artifactId>
            <version>1.6.10</version>
        </dependency>
    </dependencies>
</plugin>

Then we need to add the dependency for the serialization runtime library:

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

6.2. Kotlin Serialization Annotations

When using Kotlin Serialization, we need to define a serializer for Date type:

object DateSerializer : KSerializer<Date> {
    override val descriptor = PrimitiveSerialDescriptor("Date", PrimitiveKind.STRING)
    override fun serialize(encoder: Encoder, value: Date) = encoder.encodeString(DATE_FORMAT.format(value))
    override fun deserialize(decoder: Decoder): Date = DATE_FORMAT.parse(decoder.decodeString())
}

Then we need to annotate our classes and properties with the corresponding annotations:

@Serializable
data class SerializableProjectRepository(val url: String)

@Serializable
data class SerializableProject(
    val name: String,
    val type: ProjectType,
    @Serializable(KotlinSerializationMapHelper.DateSerializer::class) val createdDate: Date,
    val repository: SerializableProjectRepository,
    val deleted: Boolean = false,
    val owner: String?
) {
    var description: String? = null
}

Let’s create a similar SerializableProject instance:

val SERIALIZABLE_PROJECT = SerializableProject(
    name = "test1",
    type = ProjectType.APPLICATION,
    createdDate = Date(1000),
    repository = SerializableProjectRepository(url = "http://test.baeldung.com/test1"),
    owner = null
).apply {
    description = "a new project"
}

6.3. Conversion with All Properties

First, let’s create a Json object:

val JSON = Json { encodeDefaults = true }

As we can see, property encodeDefaults is set to true. This means that properties with default values will be included in the conversion.

We could convert a data object to JsonObject first and then call the JsonObject.toMap function to get a map:

JSON.encodeToJsonElement(obj).jsonObject.toMap()

However, the values in this map are objects of class JsonPrimitive. Therefore, we need to translate them to primitive types:

inline fun <reified T> toMap(obj: T): Map<String, Any?> {
    return jsonObjectToMap(JSON.encodeToJsonElement(obj).jsonObject)
}

fun jsonObjectToMap(element: JsonObject): Map<String, Any?> {
    return element.entries.associate {
        it.key to extractValue(it.value)
    }
}

private fun extractValue(element: JsonElement): Any? {
    return when (element) {
        is JsonNull -> null
        is JsonPrimitive -> element.content
        is JsonArray -> element.map { extractValue(it) }
        is JsonObject -> jsonObjectToMap(element)
    }
}

Next, we can verify the result:

val map = KotlinSerializationMapHelper.toMap(SERIALIZABLE_PROJECT)
val expected = mapOf(
    "name" to "test1",
    "type" to ProjectType.APPLICATION.name,
    "createdDate" to MapHelper.DATE_FORMAT.format(SERIALIZABLE_PROJECT.createdDate),
    "repository" to mapOf(
        "url" to "http://test.baeldung.com/test1"
    ),
    "deleted" to false.toString(),
    "owner" to null,
    "description" to "a new project"
)
assertEquals(expected, map)

We should note that the deleted value is also a String because JsonElement always saves its content as a String.

Additionally, Kotlin Serialization also supports excluding property with annotation @kotlinx.serialization.Transient.

6.4. Conversion from Map to the Data Object

Json.decodeFromJsonElement only accepts a JsonElement as parameter.  We could reverse the conversion:

val jsonObject = JSON.encodeToJsonElement(SERIALIZABLE_PROJECT).jsonObject
val newProject = JSON.decodeFromJsonElement<SerializableProject>(jsonObject)
assertEquals(SERIALIZABLE_PROJECT, newProject)

6.5. Limitations

Kotlin Serialization is string-oriented. Therefore, it may not fit well for scenarios that need original property values (boolean or Date).

7. Conclusion

In this article, we’ve seen different ways to convert a Kotlin data object to a map and vice versa. We should pick the one that fits most for us.

As always, the complete code samples for this article can be found over on GitHub.