I have a need to deserialize JSON models into a strict hierarchy of classes with a strong forwards compatibility requirement.
This is done with closed polymorphism with sealed classes according to documentation.
The models I'm working with look like this:
@Serializable
sealed class BaseChatMessageDto {
abstract val id: Int
abstract val localId: Int
abstract val fromId: Int
abstract val replyTo: Int?
abstract val remoteStatus: RemoteStatus?
abstract val createdAt: Int
abstract val type: ChatMessageType
}
ChatMessageType is an enum of message types the client is aware of. A custom serializer ensures that any unknown type is deserialized as ChatMessageType.UNKNOWN:
object ChatMessageTypeFallback :
ForwardCompatibleEnumSerializer<ChatMessageType>(ChatMessageType::class)
@Serializable(ChatMessageTypeFallback::class)
enum class ChatMessageType {
@SerialName("text")
TEXT,
@SerialName("file")
FILE,
@SerialName("photo")
PHOTO,
@SerialName("sticker")
STICKER,
@SerialName("gif")
GIF,
@SerialName("task")
TASK,
@SerialName("service")
SERVICE,
@ForwardsCompatible
@SerialName("unknown")
UNKNOWN
}
So, the type in BaseChatMessageDto serves as an implicit type discriminator for the kotlinx.serialization compiler plugin. Each of the message types must be deserialized into an object of its own class, for example:
@Serializable
@SerialName("sticker")
data class StickerChatMessageDto(
override val id: Int,
@SerialName("local_id")
override val localId: Int,
// Override the rest of BaseChatMessageDto
@SerialName("sticker_id")
val stickerId: Int?,
// Other fields specific for "sticker" type
// Override ChatMessageWithReactionsDto field
override val reactions: List<Reaction>,
): BaseChatMessageDto(), ChatMessageWithReactionsDto
Some types like FILE and PHOTO are considered rich messages, and have their own superclass:
@Serializable
sealed class RichChatMessageDto: BaseChatMessageDto(), ChatMessageWithReactionsDto {
abstract override val id: Int
@SerialName("local_id")
abstract override val localId: Int
abstract override val type: ChatMessageType
// Override the rest of BaseChatMessageDto
abstract val text: String
// Other abstract members of RichChatMessageDto
// Override ChatMessageWithReactionsDto field
abstract override val reactions: List<Reaction>?
}
@Serializable
@SerialName("file")
data class FileChatMessageDto(
override val type: ChatMessageType = ChatMessageType.FILE,
// Override everything else from RichChatMessageDto
): RichChatMessageDto()
UnknownChatMessageDto provides forwards compatibility by being deserialized into when an unknown ChatMessageType is encountered:
@Serializable
@SerialName("unknown")
data class UnknownChatMessageDto(
// Override everything.
): BaseChatMessageDto()
But there is a very special case in this logic - deleted messages. Server-side they are discriminated not by type, but by status. If status is DELETED, then the base model is returned regardless of its type. So I need to create a deserialization interceptor of sorts, that will look at status at first, use DeletedMessageDto.serializer() if necessary, otherwise proceed with default deserialization pipeline. As far as I'm aware, JsonContentPolymorphicSerializer is designed specifically for this, so:
@Serializable
data class DeletedChatMessageDto(
// Override everything
): BaseChatMessageDto()
@Serializable(
with = DeletedMessageInterceptorSerializer::class
)
sealed class BaseChatMessageDto {
// ...
}
object DeletedMessageInterceptorSerializer: JsonContentPolymorphicSerializer<BaseChatMessageDto>(
BaseChatMessageDto::class
) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<BaseChatMessageDto> {
val status = try {
val primitive = element.jsonObject["status"]?.jsonPrimitive
// Parse status into enum
} catch (e: Exception) {
e.printStackTrace()
RemoteStatus.UNKNOWN
}
return when (status) {
RemoteStatus.DELETED -> DeletedChatMessageDto.serializer()
else -> PolymorphicSerializer(BaseChatMessageDto::class)
}
}
}
But it doesn't work as expected. DeletedChatMessageDto seem to be deserialized fine, but for other types it fails:
kotlinx.serialization.json.internal.JsonDecodingException: Polymorphic serializer was not found for class discriminator 'text'
If I provide a SerializersModule to my Json like this:
serializersModule += SerializersModule {
polymorphic(BaseChatMessageDto::class) {
defaultDeserializer {
UnknownChatMessageDto.serializer()
}
}
}
Then DeletedChatMessageDtos are deserialized fine, but all other types are unsurprisingly deserialized as UnknownChatMessageDto.
If I register subclasses explicitly:
serializersModule += SerializersModule {
polymorphic(BaseChatMessageDto::class) {
subclass(TextChatMessageDto::class)
subclass(FileChatMessageDto::class)
subclass(PhotoChatMessageDto::class)
}
}
Then another exception is thrown:
Caused by: java.lang.IllegalArgumentException: Polymorphic serializer for class <package>.FileChatMessageDto (Kotlin reflection is not available) 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 instead try to use a suggestion from @simon-jacobs about using a typealias like this:
typealias BaseChatMessageDto =
@Serializable(DeletedMessageInterceptorSerializer::class)
BaseChatMessageDtoPlain
@Serializable
sealed class BaseChatMessageDtoPlain {
// ...
}
// I tried both BaseChatMessageDtoPlain and BaseChatMessageDto here
object DeletedMessageInterceptorSerializer: JsonContentPolymorphicSerializer<BaseChatMessageDtoPlain>(
BaseChatMessageDtoPlain::class
) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<BaseChatMessageDtoPlain> {
val status = // ...
return when (status) {
RemoteStatus.DELETED -> DeletedChatMessageDto.serializer()
else -> BaseChatMessageDtoPlain.serializer()
}
}
}
Then the serializer is called when BaseChatMessageDto is a property of another serializable type, but not called for a Retrofit call that returns a List<BaseChatMessageDto>, which results in exceptions for deleted messages:
kotlinx.serialization.MissingFieldException: Field 'text' is required for type with serial name 'text', but it was missing at path: $[1]
I can't quite figure out why DeletedMessageInterceptorSerializer is not applied through a typealias for this particular call.
I am aware that I could use JsonContentPolymorphicSerializer to select a serializer based on type by hand, but I want to offload as much logic to the compiler plugin for type safety, code clarity and less places a mistake could be made.
What am I doing wrong here? Is PolymorphicSerializer(BaseChatMessageDto::class) not the way to proceed with closed polymorphic deserialization pipeline?