Describe the bug
When a @serializable class has a type parameter D and a property of shape Map<ConcreteKey, GenericSealed<D>> (i.e. the map VALUE is a sealed/abstract type parameterized on the class's own type parameter), the plugin-generated serializer builds the wrong element serializer for the map value:
instead of passing the container's D type-argument serializer to the value serializer, it passes the map-KEY's serializer. At runtime, the value's D-typed field is then handed to the key serializer and a ClassCastException is thrown.
To Reproduce
package bugrepro
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
// --- type parameter bound: a sealed "witness" type carried down into the leaf ---
@Serializable
sealed interface Directionality {
@Serializable @SerialName("Gen") data object Generating : Directionality
@Serializable @SerialName("Con") data object Consuming : Directionality
}
// --- the Map KEY: a distinct @Serializable type with its own generated serializer.
// Primitive-backed so it is a valid JSON map key. Its serializer is the one that
// erroneously ends up in the map-VALUE's type-argument slot. ---
@Serializable
@JvmInline
value class SliceId(val n: Int)
// --- the Map VALUE: a generic sealed type, parameterized on the enclosing D ---
@Serializable
sealed interface Slice<out D : Directionality>
@Serializable
@SerialName("UpdateSlice")
data class UpdateSlice<out D : Directionality>(
/** A `D`-typed member: this is what gets fed to the wrong serializer. */
val direction: D,
) : Slice<D>
// ============================================================================
// BAD: Map<Key, GenericSealed<D>> -> ClassCastException at runtime
// ============================================================================
@Serializable
data class ContainerBad<out D : Directionality>(
val slices: Map<SliceId, Slice<D>>,
)
// ============================================================================
// GOOD (workaround): Map value is a BARE type parameter -> serializes correctly
// ============================================================================
@Serializable
data class ContainerGood<out D : Directionality, out S : Slice<D>>(
val slices: Map<SliceId, S>,
)
fun main() {
val bad = ContainerBad(mapOf(SliceId(1) to UpdateSlice(Directionality.Generating)))
runCatching {
Json.encodeToString(ContainerBad.serializer(Directionality.serializer()), bad)
}.onSuccess {
println("BAD unexpectedly succeeded (bug fixed?): $it")
}.onFailure {
println("BAD failed as expected -> ${it::class.qualifiedName}: ${it.message}")
}
val good = ContainerGood(mapOf(SliceId(1) to UpdateSlice(Directionality.Generating)))
val json = Json.encodeToString(
ContainerGood.serializer(Directionality.serializer(), UpdateSlice.serializer(Directionality.serializer())),
good,
)
println("GOOD serialized -> $json")
}
Expected behavior
Serializing ContainerBad(mapOf(SliceId(1) to UpdateSlice(Generating))) produces: {"slices":{"1":{"direction":{"type":"Gen"}}}}
Actual behavior
java.lang.ClassCastException: class ...Directionality$Generating cannot be cast to class ...SliceId
thrown at SliceId$$serializer.serialize(...)
called from ContainerBad.write$Self(...) while encoding the slices map value.
Mechanism
For slices: Map<SliceId, Slice<D>> the generated childSerializers() should be MapSerializer(SliceId.serializer(), Slice.serializer(<container's D serializer>)).
Instead, the value gets Slice.serializer(SliceId.serializer()) — the KEY serializer leaks into the VALUE's type-argument position.
D therefore resolves to the key serializer all the way down, so UpdateSlice.direction (a D) is serialized with SliceId.serializer() → cast fails.
Minimal trigger conditions (all required)
- The enclosing class is generic:
Container<out D : Bound>
- A property is a Map/collection whose ELEMENT is a generic type parameterized on the class's own type parameter:
Map<Key, Slice<D>>
- The element type (
Slice) is sealed/abstract and its concrete leaf actually carries a D-typed member that gets serialized: UpdateSlice(val direction: D)
- The Map KEY is a distinct @serializable type with its own generated serializer (here a primitive-backed value class, valid as a JSON map key).
A direct generic property (val x: Slice<D>, not inside a collection) serializes correctly — only the collection/map ELEMENT position is mis-wired.
Workaround (see ContainerGood)
Promote the map value to a BARE type parameter instead of a constructed generic: Container<out D : Bound, out S : Slice<D>>(val slices: Map<Key, S>)
A bare type-parameter value is threaded via its own type-argument serializer and is not mis-indexed.
Because that serializer resolves through the OPEN polymorphic scope at runtime, every concrete leaf of Slice must be registered in a SerializersModule (omitted here since the standalone repro serializes the leaf via the closed sealed serializer directly; in an app, register the leaves).
Related Issues (same childSerializers type-argument-propagation family)
Environment
- Kotlin version: 2.4.0
- Library version: kotlinx-serialization 1.11.0
- Kotlin platforms: JVM
Describe the bug
When a @serializable class has a type parameter
Dand a property of shapeMap<ConcreteKey, GenericSealed<D>>(i.e. the map VALUE is a sealed/abstract type parameterized on the class's own type parameter), the plugin-generated serializer builds the wrong element serializer for the map value:instead of passing the container's
Dtype-argument serializer to the value serializer, it passes the map-KEY's serializer. At runtime, the value'sD-typed field is then handed to the key serializer and a ClassCastException is thrown.To Reproduce
Expected behavior
Serializing
ContainerBad(mapOf(SliceId(1) to UpdateSlice(Generating)))produces:{"slices":{"1":{"direction":{"type":"Gen"}}}}Actual behavior
java.lang.ClassCastException: class ...Directionality$Generating cannot be cast to class ...SliceId
thrown at SliceId$$serializer.serialize(...)
called from ContainerBad.write$Self(...) while encoding the
slicesmap value.Mechanism
For
slices: Map<SliceId, Slice<D>>the generatedchildSerializers()should beMapSerializer(SliceId.serializer(), Slice.serializer(<container's D serializer>)).Instead, the value gets
Slice.serializer(SliceId.serializer())— the KEY serializer leaks into the VALUE's type-argument position.Dtherefore resolves to the key serializer all the way down, soUpdateSlice.direction(aD) is serialized withSliceId.serializer()→ cast fails.Minimal trigger conditions (all required)
Container<out D : Bound>Map<Key, Slice<D>>Slice) is sealed/abstract and its concrete leaf actually carries aD-typed member that gets serialized:UpdateSlice(val direction: D)A direct generic property (
val x: Slice<D>, not inside a collection) serializes correctly — only the collection/map ELEMENT position is mis-wired.Workaround (see ContainerGood)
Promote the map value to a BARE type parameter instead of a constructed generic:
Container<out D : Bound, out S : Slice<D>>(val slices: Map<Key, S>)A bare type-parameter value is threaded via its own type-argument serializer and is not mis-indexed.
Because that serializer resolves through the OPEN polymorphic scope at runtime, every concrete leaf of
Slicemust be registered in a SerializersModule (omitted here since the standalone repro serializes the leaf via the closed sealed serializer directly; in an app, register the leaves).Related Issues (same childSerializers type-argument-propagation family)
S : SealedBase)UnitSerializeras input incorrectly #1843 (wrong serializer passed to a generic serializer; links JetBrains YouTrack KT-50764).Environment