Skip to content

Generic sealed type used as a Map value: the map-KEY serializer is wired into the map-VALUE's type-argument slot, causing a ClassCastException. #3222

Description

@uliSchuster

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)

  1. The enclosing class is generic: Container<out D : Bound>
  2. A property is a Map/collection whose ELEMENT is a generic type parameterized on the class's own type parameter: Map<Key, Slice<D>>
  3. The element type (Slice) is sealed/abstract and its concrete leaf actually carries a D-typed member that gets serialized: UpdateSlice(val direction: D)
  4. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions