kotlinkotlinx.serializationkotlinx

Polymorphic kotlinx serialization when type is integer, not string


I am trying to consume and emit JSON which contains a polymorphic list of items. The problem is: the items contain type key with integer values (not strings). The API endpoint produces and expects JSON similar to this:

{
  "startTime": "2022-07-27T13:32:57.379Z",
  "items": [
    {
      "type": 0,
      "results": "string",
      "restBetweenRounds": "string"
    },
    {
      "type": 1,
      "results": "string",
      "setCount": 0
    },
    {
      "type": 2,
      "items": [
        {
          "type": 0,
          "results": "string",
          "restBetweenRounds": "string"
        },
        {
          "type": 1,
          "results": "string",
          "setCount": 0
        }
      ],
      "results": "string"
    }
  ],
  "status": 0,
  "clientId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}

As described in the article on polymorphism, I created an hierarchy of classes. I also try to convert type value before deserialization.

object MyTransformingDeserializer : JsonTransformingSerializer<BaseItem>(PolymorphicSerializer(BaseItem::class)) {
    override fun transformDeserialize(element: JsonElement): JsonElement {
        val type = element.jsonObject["type"]!!
        val newType = JsonPrimitive(value = type.toString())
        return JsonObject(element.jsonObject.toMutableMap().also { it["type"] = newType })
    }
}


@Serializable(with = MyTransformingDeserializer::class)
sealed class BaseItem {
    abstract val type: String
}

@Serializable
@SerialName("0")
class ItemType0(
    override val type: String,
    // ...
) : BaseItem()


@Serializable
@SerialName("1")
class ItemType1(
    override val type: String,
    // ...
) : BaseItem()

@Serializable
@SerialName("2")
class ItemType2(
    override val type: String,
    // ...
) : BaseItem()

But all I get is this error:

kotlinx.serialization.json.internal.JsonDecodingException: Polymorphic serializer was not found for class discriminator '0'

Given that I can not change the format of the JSON, what can be done to successfully serialize/desereialize it?


Solution

  • Handling polymorphism in Kotlinx Serialization is difficult, especially when you don't have control over the format of the source. But KxS does give a lot of low-level tools to manually handle almost anything.

    You were close in choosing JsonTransformingSerializer! It seems that it doesn't transform the JSON before KxS selects a serializer. Because discriminators can only be strings, deserialization fails.

    JsonContentPolymorphicSerializer

    Instead of JsonTransformingSerializer, you can use JsonContentPolymorphicSerializer.

    Kotlinx Serialization will first deserialize the JSON to a JsonObject. It will then provide that object to the serializer for BaseItem, and you can parse and select the correct subclass.

    import kotlinx.serialization.*
    import kotlinx.serialization.json.*
    
    object BaseItemSerializer : JsonContentPolymorphicSerializer<BaseItem>(BaseItem::class) {
      override fun selectDeserializer(
        element: JsonElement
      ): DeserializationStrategy<out BaseItem> {
    
        return when (val type = element.jsonObject["type"]?.jsonPrimitive?.intOrNull) {
          0    -> ItemType0.serializer()
          1    -> ItemType1.serializer()
          2    -> ItemType2.serializer()
          else -> error("unknown Item type $type")
        }
      }
    }
    

    Including type

    Since this is manually performing polymorphic discrimination, there's no need to include type in your classes.

    import kotlinx.serialization.Serializable
    
    @Serializable(with = BaseItemSerializer::class)
    sealed class BaseItem
    
    @Serializable
    data class ItemType0(
      // ...
    ) : BaseItem()
    
    @Serializable
    class ItemType1(
      // ...
    ) : BaseItem()
    
    @Serializable
    class ItemType2(
      // ...
    ) : BaseItem()
    

    However you might like to include it, for completeness, and so it's included when serializing. For that, you must use @EncodeDefault

    import kotlinx.serialization.EncodeDefault
    import kotlinx.serialization.Serializable
    
    @Serializable(with = BaseItemSerializer::class)
    sealed class BaseItem {
      abstract val type: Int
    }
    
    @Serializable
    class ItemType0(
      // ...
    ) : BaseItem() {
      @EncodeDefault
      override val type: Int = 0
    }
    
    // ...
    

    Complete example

    Bringing it all together, here's a complete example.

    import kotlinx.serialization.*
    import kotlinx.serialization.json.*
    
    val mapper = Json {
      prettyPrint = true
      prettyPrintIndent = "  "
    }
    
    fun main() {
    
      val json = """
    {
      "startTime": "2022-07-27T13:32:57.379Z",
      "status": 0,
      "clientId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "items": [
        {
          "type": 0,
          "results": "string",
          "restBetweenRounds": "string"
        },
        {
          "type": 1,
          "results": "string",
          "setCount": 0
        },
        {
          "type": 2,
          "items": [
            {
              "type": 0,
              "results": "string",
              "restBetweenRounds": "string"
            },
            {
              "type": 1,
              "results": "string",
              "setCount": 0
            }
          ],
          "results": "string"
        }
      ]
    }
      """.trimIndent()
    
      val itemHolder: ItemHolder = mapper.decodeFromString(json)
    
      println(itemHolder)
    
      println(mapper.encodeToString(itemHolder))
    }
    
    @Serializable
    data class ItemHolder(
      val startTime: String,
      val clientId: String,
      val status: Int,
      val items: List<BaseItem>,
    )
    
    @Serializable(with = BaseItem.Serializer::class)
    sealed class BaseItem {
      abstract val type: Int
    
      object Serializer : JsonContentPolymorphicSerializer<BaseItem>(BaseItem::class) {
        override fun selectDeserializer(
          element: JsonElement
        ): DeserializationStrategy<out BaseItem> {
    
          return when (val type = element.jsonObject["type"]?.jsonPrimitive?.intOrNull) {
            0    -> ItemType0.serializer()
            1    -> ItemType1.serializer()
            2    -> ItemType2.serializer()
            else -> error("unknown Item type $type")
          }
        }
      }  
    }
    
    @Serializable
    data class ItemType0(
      val results: String,
      val restBetweenRounds: String,
    ) : BaseItem() {
      @EncodeDefault
      override val type: Int = 0
    }
    
    @Serializable
    data class ItemType1(
      val results: String,
      val setCount: Int,
    ) : BaseItem() {
      @EncodeDefault
      override val type: Int = 1
    }
    
    @Serializable
    data class ItemType2(
      val results: String,
      val items: List<BaseItem>,
    ) : BaseItem() {
      @EncodeDefault
      override val type: Int = 2
    }
    

    This prints

    ItemHolder(
      startTime=2022-07-27T13:32:57.379Z, 
      clientId=3fa85f64-5717-4562-b3fc-2c963f66afa6, 
      status=0, 
      items=[
        ItemType0(results=string, restBetweenRounds=string), 
        ItemType1(results=string, setCount=0), 
        ItemType2(
          results=string, 
          items=[
            ItemType0(results=string, restBetweenRounds=string),
            ItemType1(results=string, setCount=0)
          ]
        )
      ]
    )
    

    and

    {
      "startTime": "2022-07-27T13:32:57.379Z",
      "clientId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "status": 0,
      "items": [
        {
          "results": "string",
          "restBetweenRounds": "string",
          "type": 0
        },
        {
          "results": "string",
          "setCount": 0,
          "type": 1
        },
        {
          "results": "string",
          "items": [
            {
              "results": "string",
              "restBetweenRounds": "string",
              "type": 0
            },
            {
              "results": "string",
              "setCount": 0,
              "type": 1
            }
          ],
          "type": 2
        }
      ]
    }
    

    which matches the input!

    Versions