The Problem
Due to project architecture, backward compatibility and so on, I need to change class discriminator on one abstract class and all classes that inherit from it. Ideally, I want it to be an enum.
I tried to use @JsonClassDiscriminator but Kotlinx still uses type member as discriminator which have name clash with member in class. I changed member name to test what will happen and Kotlinx just used type as discriminator.
Also, outside of annotations, I want to avoid changing these classes. It's shared code, so any non backward compatible changes will be problematic.
Code
I prepared some code, detached from project, that I use for testing behavior.
fun main() {
val derived = Derived("type", "name") as Base
val json = Json {
prettyPrint = true
encodeDefaults = true
serializersModule = serializers
}.encodeToString(derived)
print(json)
}
@Serializable
@JsonClassDiscriminator("source")
data class Derived(
val type: String?,
val name: String?,
) : Base() {
override val source = FooEnum.A
}
@Serializable
@JsonClassDiscriminator("source")
abstract class Base {
abstract val source: FooEnum
}
enum class FooEnum { A, B }
internal val serializers = SerializersModule {
polymorphic(Base::class) {
subclass(Derived::class)
}
}
If I don't change type member name, I got this error:
Exception in thread "main" java.lang.IllegalArgumentException: Polymorphic serializer for class my.pack.Derived has property 'type' that conflicts with JSON class discriminator. You can either change class discriminator in JsonConfiguration, rename property with @SerialName annotation or fall back to array polymorphism
If I do change the name, I got this JSON which clearly shows, that json type discriminator wasn't changed.
{
"type": "my.pack.Derived",
"typeChanged": "type",
"name": "name",
"source": "A"
}
Kotlinx Serialization doesn't allow for significant customisation of the default type discriminator - you can only change the name of the field.
Before I jump into the solutions, I want to point out that in these examples using @EncodeDefault or Json { encodeDefaults = true } is required, otherwise Kotlinx Serialization won't encode your val source.
@Serializable
data class Derived(
val type: String?,
val name: String?,
) : Base() {
@EncodeDefault
override val source = FooEnum.A
}
You can use @JsonClassDiscriminator to define the name of the discriminator
(Note that you only need @JsonClassDiscriminator on the parent Base class, not both)
However, @JsonClassDiscriminator is more like an 'alternate name', not an override. To override it, you can set classDiscriminator in the Json { } builder
val mapper = Json {
prettyPrint = true
encodeDefaults = true
serializersModule = serializers
classDiscriminator = "source"
}
You can change the value of type for subclasses though - use @SerialName("...") on your subclasses.
@Serializable
@SerialName("A")
data class Derived(
val type: String?,
val name: String?,
) : Base()
You also can't include the discriminator in your class - https://github.com/Kotlin/kotlinx.serialization/issues/1664
So there are 3 options.
Change your code to use closed polymorphism
Since Base is a sealed class, instead of an enum, you can use type-checks on any Base instance
fun main() {
val derived = Derived("type", "name")
val mapper = Json {
prettyPrint = true
encodeDefaults = true
classDiscriminator = "source"
}
val json = mapper.encodeToString(Base.serializer(), derived)
println(json)
val entity = mapper.decodeFromString(Base.serializer(), json)
when (entity) {
is Derived -> println(entity)
}
}
@Serializable
@SerialName("A")
data class Derived(
val type: String?,
val name: String?,
) : Base()
@Serializable
sealed class Base
Since Base is now sealed, it's basically the same as an enum, so there's no need for your FooEnum.
val entity = mapper.decodeFromString(Base.serializer(), json)
when (entity) {
is Derived -> println(entity)
// no need for an 'else'
}
However, you still need Json { classDiscriminator= "source" }...
Use a content-based deserializer.
This would mean you wouldn't need to make Base a sealed class, and you could manually define a default if the discriminator is unknown.
object BaseSerializer : JsonContentPolymorphicSerializer<Base>(Base::class) {
override fun selectDeserializer(element: JsonElement) = when {
"source" in element.jsonObject -> {
val sourceContent = element.jsonObject["source"]?.jsonPrimitive?.contentOrNull
when (
val sourceEnum = FooEnum.values().firstOrNull { it.name == sourceContent }
) {
FooEnum.A -> Derived.serializer()
FooEnum.B -> error("no serializer for $sourceEnum")
else -> error("'source' is null")
}
}
else -> error("no 'source' in JSON")
}
}
This is a good fit in some situations, especially when you don't have a lot of control over the source code. However, I think this is pretty hacky, and it would be easy to make a mistake in selecting the serializer.
Alternatively you can write a custom serializer.
The end result isn't that different to the content-based deserializer. It's still complicated, and is still easy to make mistakes with. For these reasons, I won't give a complete example.
This is beneficial because it provides more flexibility if you need to encode/decode with non-JSON formats.
@Serializable(with = BaseSerializer::class)
@JsonClassDiscriminator("source")
sealed class Base {
abstract val source: FooEnum
}
object BaseSerializer : KSerializer<Base> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Base") {
// We have to write our own custom descriptor, because setting a custom serializer
// stops the plugin from generating one
}
override fun deserialize(decoder: Decoder): Base {
require(decoder is JsonDecoder) {"Base can only be deserialized as JSON"}
val sourceValue = decoder.decodeJsonElement().jsonObject["source"]?.jsonPrimitive?.contentOrNull
// same logic as the JsonContentPolymorphicSerializer...
}
override fun serialize(encoder: Encoder, value: Base) {
require(encoder is JsonEncoder) {"Base can only be serialized into JSON"}
when (value) {
is Derived -> encoder.encodeSerializableValue(Derived.serializer(), value)
}
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With