Coach Roebuck
Coach Roebuck

Reputation: 1034

Kotlin Serialization for Generic Objects...?

I had the bright idea to implement a generic object and attempt to serialize / deserialize it, and received this error:

Serializer for class 'DetailsRequest' is not found.
Mark the class as @Serializable or provide the serializer explicitly.

I thought the @Serializer annotation would have achieved this... ???

The implementation of the data class, and its custom serializer (which is probably overkill), are as follows:

@Serializable(with=DetailsRequestSerializer::class)
data class DetailsRequest<out T>(val details: T)

object DetailsRequestSerializer : KSerializer<DetailsRequest<*>> {
    override val descriptor: SerialDescriptor = buildClassSerialDescriptor(
        "DetailsRequest") {
        when (this::class) {
            String::class -> element<String>("details")
            Long::class -> element<Long>("details")
            Int::class -> element<Int>("details")
            Double::class -> element<Double>("details")
            Float::class -> element<Float>("details")
            else -> element<String>("details")
        }
    }

    override fun serialize(
        encoder: Encoder,
        value: DetailsRequest<*>
    ) {
        value.details.let {
            encoder.encodeStructure(descriptor) {
                when (value::class) {
                    String::class -> encodeStringElement(descriptor, 0, value.details as String)
                    Long::class -> encodeLongElement(descriptor, 0, value.details as Long)
                    Int::class -> encodeIntElement(descriptor, 0, value.details as Int)
                    Double::class -> encodeDoubleElement(descriptor, 0, value.details as Double)
                    Float::class -> encodeFloatElement(descriptor, 0, value.details as Float)
                    else -> encodeStringElement(descriptor, 0, value.details as String)
                }
            }
        }
    }

    override fun deserialize(decoder: Decoder): DetailsRequest<*> {
        return when (this::class) {
            String::class -> DetailsRequest(decoder.decodeString())
            Long::class -> DetailsRequest(decoder.decodeLong())
            Int::class -> DetailsRequest(decoder.decodeInt())
            Double::class -> DetailsRequest(decoder.decodeDouble())
            Float::class -> DetailsRequest(decoder.decodeFloat())
            else -> DetailsRequest(decoder.decodeString())
        }
    }
}

I have written this unit test:

class PlaygroundTest {
    private val json = Json {
        encodeDefaults = true
        isLenient = true
        allowSpecialFloatingPointValues = true
        allowStructuredMapKeys = true
        prettyPrint = true
        useArrayPolymorphism = false
        ignoreUnknownKeys = true
    }

    @Test
    fun `Details Integration Tests`() {
        val input = DetailsRequest(details = "New Record")
        val value = json.encodeToString(input)
        println("value=[$value]")
        val output = value.convertToDataClass<DetailsRequest<String>>()
        println("output=[$output]")
    }

    @OptIn(InternalSerializationApi::class)
    internal inline fun <reified R : Any> String.convertToDataClass() =
        json.decodeFromString(R::class.serializer(), this)
}

Bonus points: This is a Kotlin Multiplatform project, which I do not believe will impact this situation.

Is this type of situation even possible?

Upvotes: 12

Views: 5520

Answers (1)

Rohen Giralt
Rohen Giralt

Reputation: 437

The problem you asked about

Good news: this situation is totally possible, and your code is very nearly correct. Kotlin's builtin serialization is a bit wonky about getting serializers, though, so it is a bit surprising at first that your code snippet doesn't work.

The answer lies in a bit of almost fine print in the documentation[1]:

Constraints

This paragraph explains known (but not all!) constraints of the serializer() implementation. Please note that they are not bugs, but implementation restrictions that we cannot workaround.

[...]

  • Serializers for classes with generic parameters are ignored by this method

Since DetailsRequest<out T> has that generic out T, this means that accessing its serializer through DetailsRequest::class.serializer() won't work.

Fortunately, kotlinx.serialization provides us a different method[2] to get a serializer for an arbitrary type:

inline fun <T> serializer(): KSerializer<T>

In your case, you could use it like this:

// OptIn no longer needed
internal inline fun <reified R : Any> String.convertToDataClass() =
    json.decodeFromString(serializer<R>(), this)

Note that there's an overload of json.decodeFromString that uses serializer() automatically: all you really need is

internal inline fun <reified R : Any> String.convertToDataClass() =
    json.decodeFromString<R>(this)

(In fact, at this point, you may even consider removing the convertToDataClass method entirely.)

The problem you didn't ask about

If you run the code with that change, you'll get a different error:

Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 0: Expected beginning of the string, but got {
JSON input: {
    "details": "New Record"
}

This is simply because your implementation of the deserializer is a bit off—it's trying to deserialize DetailsRequest as a single string (such as "New Record") rather than a full JSON object the way you serialized it (such as { "details": "New Record" }).

A full explanation of the issue here would take a bit more space than is reasonable for this answer, but I refer you to the very complete—if a bit long—serialization guide here. You may need to scroll up and read the rest of the Custom Serializers section to ensure you understand it all as well.

A quicker solution

If you mark DetailsRequest with simply @Serializable (without the with argument) and remove the entire DetailsRequestSerializer object, the code will run fine and serialize and deserialize as one would expect. The @Serializable annotation (without a with argument) tells the compiler to autogenerate a serializer, which it does successfully and reasonably in this case.

Unless you have a specific serialization need that this default serializer does not fulfill, I would strongly recommend simply using the default one. It's much cleaner and a lot less work. :-)

[1] You'll have to scroll down a bit; Dokka doesn't allow me to link to a specific declaration of a function. The relevant bit is all the way at the bottom.

[2] Yes, it's on the same page in the documentation. Just don't scroll down this time. :-)

Upvotes: 13

Related Questions