kotlinx.serialization icon indicating copy to clipboard operation
kotlinx.serialization copied to clipboard

How to get rid of nullability?

Open Fleshgrinder opened this issue 1 year ago • 0 comments

I'm writing Kafka serdes where null has a very special meaning (tombstones). I do want to keep the nullability information for the type parameters, but I don't want the serializer to support null. The problem is that it's not possible to unwrap a serializer that has been wrapped with nullability. As such I'm forced to check every result the serializer or deserializer gives me to ensure it doesn't return null, and fail at runtime. Here's a deserializer example:

@OptIn(ExperimentalSerializationApi::class)
public class JsonDeserializer<T>(
    private val strategy: DeserializationStrategy<T & Any>,
    json: Json? = null,
) : Deserializer<T> {
    private val json = json ?: JSON

    init {
        // We cannot do this, because of the reified factory method.
        // require(!strategy.descriptor.isNullable)
    }

    override fun deserialize(topic: String?, data: ByteArray?): T? {
        if (data == null) {
            return null
        }
        val result = try {
            json.decodeFromStream(strategy, ByteArrayInputStream(data))
        } catch (cause: kotlinx.serialization.SerializationException) {
            throw SerializationException("Kotlinx JSON deserialization failed", cause)
        }
        @Suppress("SENSELESS_COMPARISON")
        if (result == null) {
            throw SerializationException(
                "Kotlinx JSON deserialization returned null which is forbidden as it's not " +
                    "possible anymore to tell the difference between a tombstone and a datum " +
                    "that is null, the actual error is with the producer of the data, since it " +
                    "should never have produced a serialized literal JSON null value in the " +
                    "first place"
            )
        }
        return result
    }
}

public inline fun <reified T> JsonDeserializer(json: Json? = null): JsonDeserializer<T> =
    JsonDeserializer(serializer(), json)

Users who use the constructor with a manually created strategy get a serializer with no nullability, but those who use the reified factory method always get a serializer that supports null. The T & Any on the DeserializationStrategy is ignored entirely by the serializer() call.

JsonDeserializer<String>(serializer())           // 🟢 all good
JsonDeserializer<String?>(serializer<String>())  // 🟡 ok but inconvenient
JsonDeserializer<String>()                       // 🟢 all good
JsonDeserializer<String?()                       // 🔴 not good

Adding an Any bound to T would obviously work, but it would mean that it's not possible anymore to automatically create serdes for topics where the key and/or value is actually nullable (supports tombstones).

I haven't looked into why it's possible that I can pass a KSerializer<T> to a DeserializationStrategy<T & Any> and whether this is actually an issue of this library or a Kotlin issue. But, it's strange…

I have no idea on how to work around this. Obviously I could use reflection and unwrap the inner serializer from the NullableSerializer, but that wouldn't help me with custom serializers that might be created from the serializer() factory method where isNullable is true but no inner serializer exists that could be unwrapped.

I'm not sure if it's even possible to do anything about this here, and I would have used the GitHub discussion feature if available. Anyway, looking for any help, ideas, and insights.

Fleshgrinder avatar Mar 30 '24 08:03 Fleshgrinder