Search code examples
kotlinkotlinx.serialization

Decoding JSON to a class based on the value of another JSON key


I am trying to work with a REST API that returns a JSON document who's structure depends on the value of a property named type.

I have defined the main class as follows:

@Serializable class Interaction(
    val type: Byte,
    val data: InteractionData? = null
)

The structure of InteractionData depends on the value of type. This is currently an interface that the four possible structures inherit from.

If type equals 2, data should be a class named ApplicationCommandData:

@Serializable class ApplicationCommandData(
    val id: String,
    val name: String
): InteractionData

If type equals 3, data should be a class named MessageComponentData:

@Serializable class MessageComponentData(
    val custom_id: String
): InteractionData

How can I make it so that the data property is serialised as the correct class based on the value of the type property?

I have tried setting the data property to @Transient, checking the value of type, and creating a new variable with @SerialName set to data inside of the class init block but @SerialData is not valid for local variables.


Solution

  • tl;dr: skip to the full example at the bottom

    Problem summary

    You have a polymorphic class, and the type is determined by a property outside of the class.

    {
      "type": 2,  <- extract this
      "data": {   <- determined by 'type'
        "id": "0001",
        "name": "MEGATRON"
      }
    }
    

    Kotlinx Serialization provides the tools to handle this - but they need some assembly.

    JSON content based polymorphic deserialization

    Since you're working with JSON this is possible using content based polymorphic deserialization.

    Here's an initial implementation, but there's a flaw...

    object InteractionJsonSerializer : JsonContentPolymorphicSerializer<Interaction>(
      Interaction::class
    ) {
      override fun selectDeserializer(element: JsonElement): DeserializationStrategy<out Interaction> {
    
        // extract the type from the plain JSON object
        val type = element.jsonObject["type"]?.jsonPrimitive?.intOrNull
    
        println("found InteractionData type: $type")
    
        return when (type) {
                  // can't specify the type of InteractionData
          2    -> Interaction.serializer()
          3    -> Interaction.serializer()
          else -> error("unknown type $type")
        }
      }
    }
    

    It's not possible to select a specific serializer, because Interaction doesn't have a type parameter, so let's add one.

    @Serializable
    data class Interaction<T : InteractionData?>( // add a type parameter
      val type: Byte,
      val data: T? = null
    )
    

    Now the Kotlinx Serialization plugin will generate a serializer that accepts a serializer for T: InteractionData. We can update InteractionJsonSerializer to make use of this.

    object InteractionJsonSerializer : JsonContentPolymorphicSerializer<Interaction<*>>(
      Interaction::class
    ) {
      override fun selectDeserializer(element: JsonElement): DeserializationStrategy<out Interaction<*>> {
        val type = element.jsonObject["type"]?.jsonPrimitive?.intOrNull
    
        println("found InteractionData type: $type")
    
        return when (type) {
                  // now the type can be specified
          2    -> Interaction.serializer(ApplicationCommandData.serializer())
          3    -> Interaction.serializer(MessageComponentData.serializer())
          else -> error("unknown type $type")
        }
      }
    }
    

    Complete example

    Here's a complete, runnable example, with all the imports.

    I made a couple of tweaks to your code.

    • I made InteractionData a sealed interface, because it seemed appropriate
    • I converted the classes to data classes, so Kotlin generates a nice toString().
    import kotlinx.serialization.DeserializationStrategy
    import kotlinx.serialization.Serializable
    import kotlinx.serialization.json.Json
    import kotlinx.serialization.json.JsonContentPolymorphicSerializer
    import kotlinx.serialization.json.JsonElement
    import kotlinx.serialization.json.intOrNull
    import kotlinx.serialization.json.jsonObject
    import kotlinx.serialization.json.jsonPrimitive
    
    fun main() {
      val interactionType2 =
        Json.decodeFromString(
          InteractionJsonSerializer,
          /*language=JSON*/
          """
            {
              "type": 2,
              "data": {  
                "id": "0001",
                "name": "MEGATRON"
              }
            }
          """.trimIndent()
        )
    
      println(interactionType2)
    
      val interactionType3 =
        Json.decodeFromString(
          InteractionJsonSerializer,
          /*language=JSON*/
          """
            {
              "type": 3,
              "data": {
                "custom_id": "abc123"
              }
            }
          """.trimIndent()
        )
    
      println(interactionType3)
    }
    
    
    @Serializable
    data class Interaction<T : InteractionData?>(
      val type: Byte,
      val data: T? = null
    )
    
    sealed interface InteractionData
    
    @Serializable
    data class ApplicationCommandData(
      val id: String,
      val name: String
    ) : InteractionData
    
    @Serializable
    data class MessageComponentData(
      val custom_id: String
    ) : InteractionData
    
    
    object InteractionJsonSerializer : JsonContentPolymorphicSerializer<Interaction<*>>(
      Interaction::class
    ) {
      override fun selectDeserializer(element: JsonElement): DeserializationStrategy<out Interaction<*>> {
        val type = element.jsonObject["type"]?.jsonPrimitive?.intOrNull
    
        println("found InteractionData type: $type")
    
        return when (type) {
          2    -> Interaction.serializer(ApplicationCommandData.serializer())
          3    -> Interaction.serializer(MessageComponentData.serializer())
          else -> error("unknown type $type")
        }
      }
    }
    

    Output

    found InteractionData type: 2
    Interaction(type=2, data=ApplicationCommandData(id=0001, name=MEGATRON))
    found InteractionData type: 3
    Interaction(type=3, data=MessageComponentData(custom_id=abc123))
    

    Versions

    • Kotlin 1.7.21
    • Kotlinx Serialization 1.4.1