Kotlin serialization of a generic interface

Viewed 2723

Trying kotlin 1.4.32 / serialization 1.1.0 (and kotlin 1.5.0 / serialization 1.2.0) I could not find a way to serialize the following hierarchy of classes

interface Range<T:Comparable<T>>

@Serializable @SerialName("range")
class SimpleRange<T:Comparable<T>>: Range<T>

@Serializable @SerialName("multirange")
class MultiRange<T:Comparable<T>>: Range<T>

I could serialize a SimpleRange<Double> (declared as a Range<Double>) with a SerializersModule including

polymorphic(Range::class) {
    subclass(SimpleRange.serializer(Double.serializer()))
}

But I could not find a way to configure the module in such a way that it can serialize/deserialize a SingleRange<Double> or a SingleRange<Int> or a MultiRange<String> or any combination I could declare in the SerializersModule. For example if I add subclass(SimpleRange.serializer(Int.serializer()) to the previous, I get a SerializerAlreadyRegisteredException

2 Answers

For dummy classes without generic fields this should be enough:

val module = SerializersModule {
    polymorphic(Range::class) {
        subclass(SimpleRange.serializer(PolymorphicSerializer(Comparable::class)))
        subclass(MultiRange.serializer(PolymorphicSerializer(Comparable::class)))
    }
}

But if you have a fields with type T : Comparable<T> in these classes, then it's more tricky. Generally, you would need to declare polymorphic serializer for Comparable interface too, but the problem is that you can't register "primitive types" (String, Int, Double, etc.) as subclasses for polymorphic serialization (limitation of kotlinx.serialization). And these types are crutial in this case.

As a workaround you may do the following:

  1. Use kotlinx.serialization 1.2.0, which allows to declare contextual serializer for generic classes, so your module should become:
@ExperimentalSerializationApi
val module = SerializersModule {
    contextual(Comparable::class) { it[0] } //use serializer of its first type parameter
}
  1. Redeclare your fields of T type as Comparable<T> (essentialy it's the same in this case, but otherwise you'll get cryptic error message java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0) and mark them with @Contextual annotation:
@Serializable
@SerialName("range")
class SimpleRange<T : Comparable<T>>(@Contextual val min: Comparable<T>, @Contextual val max: Comparable<T>) : Range<T>
  1. Unfortunately, it's impossible to combine polymorphic and contextual serializers for generic classes directly in serializers module:
//Will not work due to loss of information about type parameter
polymorphic(Range::class) {
    subclass(SimpleRange.serializer(ContextualSerializer(Comparable::class)))
    subclass(MultiRange.serializer(ContextualSerializer(Comparable::class)))
}

So, in order to preserve type parameter information, subclass serializer should be selected manually via auxiliary function with reified type paramters:

@Suppress("UNCHECKED_CAST")
inline fun <reified T : Range<K>, reified K : Comparable<K>> rangeSerializer(value: T): KSerializer<T> = when (value) {
    is SimpleRange<*> -> serializer<SimpleRange<K>>()
    is MultiRange<*> -> serializer<MultiRange<K>>()
    else -> throw NotImplementedError() // still required even for sealed interfaces, KT-21908
} as KSerializer<T>

Usage:

@ExperimentalSerializationApi
fun main() {
    val kotlinx = Json {
        serializersModule = module
    }

    val range1: SimpleRange<Int> = SimpleRange(1, 2)
    println(kotlinx.encodeToString(rangeSerializer(range1), range1)) // {"min":1,"max":2}
    val range2: SimpleRange<Double> = SimpleRange(1.0, 2.0)
    println(kotlinx.encodeToString(rangeSerializer(range2), range2)) // {"min":1.0,"max":2.0}
    val range3: Range<Double> = range2
    println(kotlinx.encodeToString(rangeSerializer(range3), range3)) // {"min":1.0,"max":2.0}
}

UPDATE

(Deserialization)

Since we abandoned polymorphic serialization when serialized this data, there is no class discriminator field in the resulting JSON to figure out what subclass of Range should be instantiated. But even if it were there, it's not enough - we need class discriminator for Comparable as well.

So, we need some heuristics to implement content-based polymorphic deserialization.

For the sake of these heuristics simplicity, I'd suggest to add dedicated fields for this: type (could be configured with classDiscriminator property) and comparableType respectfully. So, serializer should be tweaked:

@ExperimentalSerializationApi
inline fun <reified T : Range<K>, reified K : Comparable<K>> rangeSerializer(value: T): SerializationStrategy<T> =
    object : SerializationStrategy<T> {
        @Suppress("UNCHECKED_CAST")
        private val rangeSerializer = when (value) {
            is SimpleRange<*> -> serializer<SimpleRange<K>>()
            is MultiRange<*> -> serializer<MultiRange<K>>()
            else -> throw NotImplementedError() // still required even for sealed interfaces, KT-21908
        } as KSerializer<T>

        override val descriptor = rangeSerializer.descriptor

        override fun serialize(encoder: Encoder, value: T) = with(encoder as JsonEncoder) {
            val jsonElement = json.encodeToJsonElement(rangeSerializer, value)
            encodeJsonElement(transformSerialize(jsonElement, json))
        }

        private fun transformSerialize(element: JsonElement, json: Json) = JsonObject(element.jsonObject.toMutableMap().also {
            val typeSerialName = value::class.findAnnotation<SerialName>()?.value ?: value::class.simpleName!!
            it[json.configuration.classDiscriminator] = JsonPrimitive(typeSerialName)
            it["comparableType"] = JsonPrimitive(K::class.simpleName)
        })
    }

Now, respectful polymorhic deserializer could be declared:

@ExperimentalSerializationApi
@ExperimentalStdlibApi
object RangeDeserializer : DeserializationStrategy<Range<*>> {
    private val comparableSerializers = buildMap {
        registerSerializerFor<Int>()
        registerSerializerFor<Double>()
        registerSerializerFor<String>()
    }

    @InternalSerializationApi
    override val descriptor = buildSerialDescriptor("RangeDeserializer", PolymorphicKind.SEALED)

    override fun deserialize(decoder: Decoder): Range<*> = with(decoder as JsonDecoder) {
        val jsonObject = decodeJsonElement().jsonObject
        val deserializer = selectDeserializer(jsonObject, json)
        json.decodeFromJsonElement(deserializer, transformDeserialize(jsonObject, json))
    }

    private fun selectDeserializer(jsonObject: JsonObject, json: Json): DeserializationStrategy<out Range<*>> {
        val type = jsonObject[json.configuration.classDiscriminator]!!.jsonPrimitive.content
        val comparableType = jsonObject["comparableType"]!!.jsonPrimitive.content
        val comparableSerializer = comparableSerializers[comparableType]!!
        return when (type) {
            "range" -> SimpleRange.serializer(comparableSerializer)
            else -> MultiRange.serializer(comparableSerializer)
        }
    }

    //remove extra fields (which we introduced as heuristics for actual serializer selection) before further JSON processing to avoid requiring `ignoreUnknownKeys = true`
    private fun transformDeserialize(jsonObject: JsonObject, json: Json) = JsonObject(jsonObject.toMutableMap().also {
        it.remove(json.configuration.classDiscriminator)
        it.remove("comparableType")
    })
}

private inline fun <reified T : Comparable<T>> MutableMap<String, KSerializer<*>>.registerSerializerFor() =
        put(T::class.simpleName!!, serializer<T>())

Usage:

@ExperimentalSerializationApi
@ExperimentalStdlibApi
fun main() {
    val kotlinx = Json {
        serializersModule = module
    }
    val range1: SimpleRange<Int> = SimpleRange(1, 2)
    val encoded1 = kotlinx.encodeToString(rangeSerializer(range1), range1)
    println(encoded1) // {"min":1,"max":2,"type":"range","comparableType":"Int"}
    val range2: SimpleRange<Double> = SimpleRange(1.0, 2.0)
    val encoded2 = kotlinx.encodeToString(rangeSerializer(range2), range2)
    println(encoded2) // {"min":1.0,"max":2.0,"type":"range","comparableType":"Double"}
    val range3: Range<Double> = range2
    println(kotlinx.encodeToString(rangeSerializer(range3), range3)) // {"min":1.0,"max":2.0,"type":"range","comparableType":"Double"}

    val range1Decoded: Range<*> =
        kotlinx.decodeFromString(RangeDeserializer, encoded1) // SimpleRange(min=1, max=2)
    val range2Decoded: Range<*> =
        kotlinx.decodeFromString(RangeDeserializer, encoded2) // SimpleRange(min=1.0, max=2.0)
}

If you can come up with better heuristics for actual serializers selection (based solely on shape of the original JSON, without introducing extra fields), then you can retain original serializer and enjoy more concise JSON.

I finally could get a solution adding two more steps to Михаил Нафталь advice :

1/ add a polymorphic(Comparable::class) in the module, which throws

Serializer for Double of kind DOUBLE cannot be serialized polymorphically with class discriminator

2/ add the useArrayPolymorphism = true attribute in the json builder, which eliminates the previous exception.

Complete solution looks this :

interface Range<T:Comparable<T>>

@Serializable @SerialName("range")
class SimpleRange<T:Comparable<T>>: Range<T> {//implementation}

@Serializable @SerialName("multirange")
class MultiRange<T:Comparable<T>>: Range<T> {//implementation}

val rangeModule = SerializersModule {

    polymorphic(Comparable::class) {
        subclass(Double.serializer())
        subclass(Int.serializer())
    }

    polymorphic(Range::class) {
        subclass(SimpleRange.serializer(PolymorphicSerializer(Comparable::class)))
        subclass(MultiRange.serializer(PolymorphicSerializer(Comparable::class)))
    }

}

// Build a range here
val range = ...
println(Json{
            useArrayPolymorphism  = true
            serializersModule = (rangeModule)
        }.encodeToString(range1))

Result is not the most elegant (adding type attributes for primitives) but it works. Here is what I get (depends on the specific implementation) :

["range",{"min":["kotlin.Double",10.0],"max":["kotlin.Double",20.0]}]
Related