Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Polymorphic Serialization Mistakenly Triggered for Generic Property with Directly Serializable Type #2953

Open
CyanTachyon opened this issue Mar 14, 2025 · 7 comments
Assignees

Comments

@CyanTachyon
Copy link

Describe the bug
When serializing a sealed interface's generic property with a directly serializable type (e.g., Int or another @serializable class), the Kotlinx Serialization library incorrectly attempts polymorphic serialization, leading to a SerializationException.

To Reproduce

import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule

@Serializable
sealed interface Test<X>
{
    val x: X
}

@Serializable
data class Test1<Y>(override val x: Y): Test<Y>

@Serializable
data class Test2(val x: Int)

fun main()
{
    val json = Json {
        serializersModule = SerializersModule {
            contextual(Int::class, Int.serializer())
            contextual(Test2::class, Test2.serializer())
        }
    }
    val x: Test<Int> = Test1(1)
    val y: Test<Test2> = Test1(Test2(1))
    runCatching { json.encodeToString(x) }.onFailure(Throwable::printStackTrace) // error
    runCatching { json.encodeToString(y) }.onFailure(Throwable::printStackTrace) // error
}

error:

kotlinx.serialization.SerializationException: Serializer for subclass 'Int' is not found in the polymorphic scope of 'Any'.
Check if class with serial name 'Int' exists and serializer is registered in a corresponding SerializersModule.
To be registered automatically, class 'Int' has to be '@Serializable', and the base class 'Any' has to be sealed and '@Serializable'.
	at kotlinx.serialization.internal.AbstractPolymorphicSerializerKt.throwSubtypeNotRegistered(AbstractPolymorphicSerializer.kt:102)
	at kotlinx.serialization.internal.AbstractPolymorphicSerializerKt.throwSubtypeNotRegistered(AbstractPolymorphicSerializer.kt:114)
	at kotlinx.serialization.PolymorphicSerializerKt.findPolymorphicSerializer(PolymorphicSerializer.kt:109)
	at kotlinx.serialization.json.internal.StreamingJsonEncoder.encodeSerializableValue(StreamingJsonEncoder.kt:250)
	at kotlinx.serialization.encoding.AbstractEncoder.encodeSerializableElement(AbstractEncoder.kt:80)
	at Test1.write$Self$KotlinTest(Main2.kt:12)
	at Test1$$serializer.serialize(Main2.kt:12)
	at Test1$$serializer.serialize(Main2.kt:12)
	at kotlinx.serialization.json.internal.StreamingJsonEncoder.encodeSerializableValue(StreamingJsonEncoder.kt:259)
	at kotlinx.serialization.json.internal.JsonStreamsKt.encodeByWriter(JsonStreams.kt:99)
	at kotlinx.serialization.json.Json.encodeToString(Json.kt:125)
	at Main2Kt.main(Main2.kt:32)
	at Main2Kt.main(Main2.kt)
kotlinx.serialization.SerializationException: Serializer for subclass 'Test2' is not found in the polymorphic scope of 'Any'.
Check if class with serial name 'Test2' exists and serializer is registered in a corresponding SerializersModule.
To be registered automatically, class 'Test2' has to be '@Serializable', and the base class 'Any' has to be sealed and '@Serializable'.
	at kotlinx.serialization.internal.AbstractPolymorphicSerializerKt.throwSubtypeNotRegistered(AbstractPolymorphicSerializer.kt:102)
	at kotlinx.serialization.internal.AbstractPolymorphicSerializerKt.throwSubtypeNotRegistered(AbstractPolymorphicSerializer.kt:114)
	at kotlinx.serialization.PolymorphicSerializerKt.findPolymorphicSerializer(PolymorphicSerializer.kt:109)
	at kotlinx.serialization.json.internal.StreamingJsonEncoder.encodeSerializableValue(StreamingJsonEncoder.kt:250)
	at kotlinx.serialization.encoding.AbstractEncoder.encodeSerializableElement(AbstractEncoder.kt:80)
	at Test1.write$Self$KotlinTest(Main2.kt:12)
	at Test1$$serializer.serialize(Main2.kt:12)
	at Test1$$serializer.serialize(Main2.kt:12)
	at kotlinx.serialization.json.internal.StreamingJsonEncoder.encodeSerializableValue(StreamingJsonEncoder.kt:259)
	at kotlinx.serialization.json.internal.JsonStreamsKt.encodeByWriter(JsonStreams.kt:99)
	at kotlinx.serialization.json.Json.encodeToString(Json.kt:125)
	at Main2Kt.main(Main2.kt:33)
	at Main2Kt.main(Main2.kt)

Expected behavior
no error and successful serialization

Environment

  • Kotlin version: 2.1.10
  • Library version: 1.8.0
  • Kotlin platforms: JVM
  • Gradle version: 8.7

My English is not good, and some of the above content is from AI translation. If it is inaccurate, please forgive me.

@pdvrieze
Copy link
Contributor

Your English is good enough. Unfortunately you have hit a fundamental limitation with sealed interfaces/classes. In this case the parent Test does not have a type parameter, but the child Test1 does.
The way sealed serializers work is that they contain a list of the child serializers. Obviously Test (and its serializer) does not know about the type parameter - this means it can not create the parameterised serializer with the specific serializer for the child type (Int or Test2).

There is a case to be made to disallow this case entirely (with a good error message that explains the problem with this), but the current solution also has merit. It chooses to use a PolymorphicSerializer with the type bound (Any in this case) as base type.

The way to solve it is to have the base type have a type parameter. If you have subtypes that don't need the parameter and have out variance, you can have them use Nothing as parameter (for example when serializing "result or error" types).

@sandwwraith
Copy link
Member

I think this may have been fixed in 2.2, cc @shanshin

@CyanTachyon
Copy link
Author

Your English is good enough. Unfortunately you have hit a fundamental limitation with sealed interfaces/classes. In this case the parent Test does not have a type parameter, but the child Test1 does. The way sealed serializers work is that they contain a list of the child serializers. Obviously Test (and its serializer) does not know about the type parameter - this means it can not create the parameterised serializer with the specific serializer for the child type (Int or Test2).

There is a case to be made to disallow this case entirely (with a good error message that explains the problem with this), but the current solution also has merit. It chooses to use a PolymorphicSerializer with the type bound (Any in this case) as base type.

The way to solve it is to have the base type have a type parameter. If you have subtypes that don't need the parameter and have out variance, you can have them use Nothing as parameter (for example when serializing "result or error" types).

emmm, but it is a type parameter named X in the base class Test, which is in the To Reproduce?

@pdvrieze
Copy link
Contributor

emmm, but it is a type parameter named X in the base class Test, which is in the To Reproduce?

You are right, and as @sandwwraith said, this is hopefully fixed with the Kotlin 2.2 release.

@shanshin
Copy link
Contributor

shanshin commented Mar 18, 2025

Recent Kotlin develop and runtime 1.8.0 prints

{"type":"TestClass.Test1","x":1}
{"type":"TestClass.Test1","x":{"x":1}}

@CyanTachyon
Copy link
Author

Recent Kotlin develop and runtime 1.8.0 prints

{"type":"TestClass.Test1","x":1}
{"type":"TestClass.Test1","x":{"x":1}}

I discovered a new error, and I'm unsure if I should open a new issue:

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
sealed interface Test<T>
{
    val x: T
}

@Serializable
data class Test1<Y>(override val x: Y): Test<Y>

@Serializable
data class Test2<Y>(val x: Test<Y>)

fun main()
{
    println(Json.encodeToString(Test2(Test1(1)))) //error
}

This code produces the same error in both versions 2.1.10 and 2.2.0-dev:

Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class kotlin.Unit (java.lang.Integer is in module java.base of loader 'bootstrap'; kotlin.Unit is in unnamed module of loader 'app')
	at kotlinx.serialization.internal.UnitSerializer.serialize(Primitives.kt:68)
	at kotlinx.serialization.json.internal.StreamingJsonEncoder.encodeSerializableValue(StreamingJsonEncoder.kt:259)
	at kotlinx.serialization.encoding.AbstractEncoder.encodeSerializableElement(AbstractEncoder.kt:80)
	at Test1.write$Self$KotlinTest(Main2.kt:10)
	at Test1$$serializer.serialize(Main2.kt:10)
	at Test1$$serializer.serialize(Main2.kt:10)
	at kotlinx.serialization.json.internal.StreamingJsonEncoder.encodeSerializableValue(StreamingJsonEncoder.kt:259)
	at kotlinx.serialization.encoding.AbstractEncoder.encodeSerializableElement(AbstractEncoder.kt:80)
	at Test2.write$Self$KotlinTest(Main2.kt:13)
	at Test2$$serializer.serialize(Main2.kt:13)
	at Test2$$serializer.serialize(Main2.kt:13)
	at kotlinx.serialization.json.internal.StreamingJsonEncoder.encodeSerializableValue(StreamingJsonEncoder.kt:259)
	at kotlinx.serialization.json.internal.JsonStreamsKt.encodeByWriter(JsonStreams.kt:99)
	at kotlinx.serialization.json.Json.encodeToString(Json.kt:125)
	at Main2Kt.main(Main2.kt:20)
	at Main2Kt.main(Main2.kt)

I observed that the plugin-generated write$Self method for Test2 fails to pass the generic parameter's serializer to the Test serializer, causing this error.

@CyanTachyon
Copy link
Author

This error is very similar to the one above, with the only difference being that there is an additional class of wrapping around Test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants