kotlinserializationenumsfallback

Deserialize Kotlin enum while ignoring unknown values


I have an enum that I'd like to deserialize from JSON using kotlinx.serialization while ignoring unknown values. This is the enum

@Serializable
enum class OperatingMode {
    Off, On, Auto
}

What I mean by ignoring unknowns is if I have a mode or modes in a JSON object which are not in that enum, they should be treated as absent:

{"foo":"bar","mode":"Banana"}
// same as
{"foo":"bar"}

{"modes":["Off","On","Banana"]}
// same as
{"modes":["Off","On"]}

I got this to work by writing custom serializers, but it seems quite verbose for such a simple task

internal object OperatingModeSafeSerializer : KSerializer<OperatingMode?> {
    override val descriptor = PrimitiveSerialDescriptor("OperatingMode", PrimitiveKind.STRING)

    override fun serialize(encoder: Encoder, value: OperatingMode?) {
        // safe because @Serializable skips null fields
        encoder.encodeString(value!!.name)
    }

    override fun deserialize(decoder: Decoder): OperatingMode? {
        val string = decoder.decodeString()
        return try {
            OperatingMode.valueOf(string)
        } catch (_: Exception) {
            null
        }
    }
}

internal object OperatingModeSafeListSerializer: KSerializer<List<OperatingMode>> {
    private val delegateSerializer = ListSerializer(OperatingModeSafeSerializer)
    override val descriptor = delegateSerializer.descriptor

    override fun deserialize(decoder: Decoder): List<OperatingMode> {
        return decoder.decodeSerializableValue(delegateSerializer).filterNotNull()
    }

    override fun serialize(encoder: Encoder, value: List<OperatingMode>) {
        encoder.encodeSerializableValue(delegateSerializer, value)
    }
}

Then in each object which is deserializing OperatingMode I can add

@Serializable(with = OperatingModeSafeSerializer::class) // or
@Serializable(with = OperatingModeSafeListSerializer::class)

to ignore unknowns.

Problems

  1. This is a huge amount of code. I expect to have more enums for which I will need the exact same behavior and I really don't want to copy-paste this for each one. I don't know how to make this approach generic because these are objects which can't be generic and @Serializable(with = ...) needs a compile-time constant.
  2. Ideally I'd want this behavior to be encapsulated in the enum itself, so that anything deserializing an OperatingMode will ignore unknowns.

Solution

  • I found a way to refactor the approach in my question to avoid most of the code duplication.

    open class SafeSerializer<T>(
        private val serializer: KSerializer<T>
    ): KSerializer<T?> {
        override val descriptor = serializer.descriptor
    
        // safe because @Serializable skips null fields
        override fun serialize(encoder: Encoder, value: T?) = encoder.encodeSerializableValue(serializer, value!!)
    
        override fun deserialize(decoder: Decoder): T? = try {
            decoder.decodeSerializableValue(serializer)
        } catch (_: Exception) {
            null
        }
    }
        
    open class NonNullListSerializer<T>(
        serializer: KSerializer<T?>
    ): KSerializer<List<T>> {
        private val delegateSerializer = ListSerializer(serializer)
        override val descriptor = delegateSerializer.descriptor
    
        override fun serialize(encoder: Encoder, value: List<T>) = encoder.encodeSerializableValue(delegateSerializer, value)
    
        override fun deserialize(decoder: Decoder): List<T> = decoder.decodeSerializableValue(delegateSerializer).filterNotNull()
    }
    

    Then for each enum you want to deserialize in this way, you simply declare

    object OperatingModeSafeSerializer: SafeSerializer<OperatingMode>(OperatingMode.serializer())
    object OperatingModeSafeListSerializer: NonNullListSerializer<OperatingMode>(OperatingModeSafeSerializer)
    

    N.B. this probably only works with basic types like enums. If you try to use SafeSerializer with a complex type, the decoder might throw an exception in the middle of parsing a structure such as a JSON array or object. Catching that error will leave the decoder in an invalid state. One option would be to change deserialize to first decode a generic JSON element without catching errors, then decode that to T while catching, but that's at the cost of tightly coupling the serializer to JSON.

    If kotlinx.serialization had less greedy parsers that tried to fully consume elements before throwing, this would be a non-issue.