Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

@JsonClassDiscriminator doesn't change json class discriminator

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"
}
like image 330
Elas Avatar asked Nov 15 '25 23:11

Elas


1 Answers

Kotlinx Serialization doesn't allow for significant customisation of the default type discriminator - you can only change the name of the field.

Encoding default fields

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
}

Changing the discriminator field

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"
  }

Discriminator value

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()

Including the discriminator in a class

You also can't include the discriminator in your class - https://github.com/Kotlin/kotlinx.serialization/issues/1664

So there are 3 options.

Closed polymorphism

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" }...

Content-based deserializer

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.

Custom 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)
    }
  }
}
like image 199
aSemy Avatar answered Nov 18 '25 12:11

aSemy