Closed polymorphism with kotlinx.serialization (and KMongo)

Hello!

I’ve been developing something with MongoDB, where I store store documents which share a a set of common fields and then can be one of a closed set of variants, each with its own set of specific fields. While quickly prototyping, I just had a single data class with all variant-specific fields nullable and had a type discriminant field + a simple enum of all variants. Very hacky, I know.

Now that I’m starting to work on writing this properly, my first thought was to model this with sealed classes. Sometimes I need to filter for specific types of documents, and since it was also useful in many other places, I’d like to keep the type field in my sealed class. This creates a duplication of fields, however, since kx.ser adds a type field (or ___type in case of a name clash, as is the case here) with the class FQN. I’d like to avoid that completely and just use the enum (serialized as its name string).

Is there any way to achieve this? If not, what do you suggest I do instead?

Thank you in advance :smiley:

1 Like

Is there really any need for that? If you have sealed classes you can simply filter objects by using .filterIsInstance() or when().

If you are set on having enum as a field you could do it this way:

enum class EnumType {
	TYPE_A, TYPE_B
}

@Serializable
sealed interface SealedType {
	val type: EnumType
}

@Serializable
@SerialName("TYPE_A")
data class SealedTypeA(val intProperty: Int) : SealedType {
	override val type get() = EnumType.TYPE_A
}

@Serializable
@SerialName("TYPE_B")
data class SealedTypeB(val stringProperty: String) : SealedType {
	override val type get() = EnumType.TYPE_B
}

While that technically could work, you don’t ever want to do that on the Kotlin side, it should be done as a database filter operation. In case of KMongo, you can do stuff like Foo::type eq ... to construct a database filter.

Another technically possible option would be to split the documents into separate collections, but that’s not actually feasible either, because I also need to query based on the common type, and that would imply querying several different collections and somehow join them. Not possible.

That is what I have right now, but it duplicates the type discriminator, since it serializes SealedType::type field and then kx.ser’s polymorphism type (which gets renamed to ___type to avoid clashing). I end up with a serialization like this (not necessarily JSON):

{"type": "TYPE_A", "___type": "TYPE_A", "intProperty": 123}

My question is if I can make kx.ser just use SealedType::type and avoid adding ___type.

Not exactly. The example I provided doesn’t generate a duplicate type field. That is because there are no backing fields when using get() construct.

Ah I see. Will have to try it out to see if it plays well with KMongo’s filtering API. I also noticed you used sealed interface, which means it wouldn’t be possible to deserialize to SealedType (for when I’m working with common fields only), correct?

Alright, this seems to work well (with a sealed class in my case), although kx.ser still adds those prefixing underscores (possible because it sees the type field). Is there any way to tell it to just name it type?

Thanks a lot for the help! :3

Here is an alternative way to implement this. Although it just further proofs for me that this is just a combersome idea. :slight_smile:

enum class EnumType {
	TYPE_A, TYPE_B
}

@Serializable(SealedTypeSerializer::class)
sealed interface SealedType {
	val type: EnumType
}

object SealedTypeSerializer : JsonContentPolymorphicSerializer<SealedType>(SealedType::class) {
	override fun selectDeserializer(element: JsonElement) =
		when (enumValueOf<EnumType>(element.jsonObject.getValue("type").jsonPrimitive.content)) {
			EnumType.TYPE_A -> SealedTypeA.serializer()
			EnumType.TYPE_B -> SealedTypeB.serializer()
		}
}

@Serializable
data class SealedTypeA(val intProperty: Int) : SealedType {
	@EncodeDefault
	override val type = EnumType.TYPE_A
}

@Serializable
data class SealedTypeB(val stringProperty: String) : SealedType {
	@EncodeDefault
	override val type = EnumType.TYPE_B
}
1 Like

Mmm, that’s a bit more contrived, for sure. What do you suggest, then? I want to have the sealed hierarchy for better type safety in the code, but I also kinda need that type field to filter documents with KMongo.

Hey @madmax1028! I’m the author of KtMongo, a new driver for MongoDB. I’m very interested in upstreaming this solution, but I’m wondering if it can be reduced further.

For example, an easy footgun in this implementation is that the user writing a subclass must declare type = <the correct enum subtype>. Let’s try to fix that.

What if KtMongo provided an interface:

interface MongoClosedPolymorphism<out E : Enum<E>> {
    val type: E
}

fun <E : Enum<E>> MongoClosedPolymorphism(e: E): MongoClosedPolymorphism<E> =
    object : MongoClosedPolymorphism<E> {
        override val type: E get() = e
    }

// and the serializer that's convenient

This way, a user of the library would have to write:

enum class EnumType {
	TYPE_A, TYPE_B
}

@Serializable(MongoClosedPolymorphismSerializer::class)
sealed class SealedType : MongoClosedPolymorphism<EnumType> {
    // …
}

@Serializable
data class SealedTypeA(
    val intProperty: Int,
) : SealedType, MongoClosedPolymorphism<EnumType> by MongoClosedPolymorphism<EnumType.A>

@Serializable
data class SealedTypeB(
    val intProperty: Int,
) : SealedType, MongoClosedPolymorphism<EnumType> by MongoClosedPolymorphism<EnumType.B>

That seems a bit better, but I’m sure it can be improved further :thinking:

The main two problems left, I think, are:

  • It’s still quite verbose
  • If you forget to annotate the sealed type with @Serializable(…Serializer::class) then it won’t work