skip to content

You have a sealed @Serializable Result<T> hierarchy and you also nest one sealed type as a property of another. What subtleties arise with discriminator collisions, generic type arguments, and decoding the base vs a concrete subtype?

level: seniorimportance: nice to knowfreq 25%

answer

  1. encode through BASE type or no discriminator appears
  2. generic: supply element serializer; T keeps its own serializer
  3. nested sealed property => independent discriminator
  4. discriminator key must not collide with a property name
  5. @JsonClassDiscriminator scopes the key per hierarchy

basics

~20 s

Polymorphic encoding only happens when you serialize through the base type, not a concrete subtype. Generic type arguments still need their own serializers, and a nested sealed property must itself be polymorphically encoded. The discriminator key must not collide with a real field name.

solid answer

~40 s

Three subtleties. First, dispatch depends on the static type you serialize through: encodeToString<Base>(value) emits the discriminator; encodeToString(concreteSubtype) without the base type often serializes it as just that class, with no tag — breaking round-trips. Use the base serializer or PolymorphicSerializer explicitly. Second, generics: a sealed Result<T> needs a serializer for T; the plugin generates Result.serializer(elementSerializer), and the discriminator tags the Result subtype while T is serialized by its own serializer — T itself can be polymorphic if it's a registered base. Third, a sealed type used as a property is auto-polymorphic, so the discriminator key (default "type") must not collide with any actual property name in any subtype, or you get a conflict exception; rename via classDiscriminator. Nested sealed types each carry their own discriminator independently.

code

kotlin · 8 lines
kotlin
@Serializable
@JsonClassDiscriminator("kind") // scope discriminator key to avoid collision
sealed class Node {
    @Serializable @SerialName("leaf") data class Leaf(val v: Int) : Node()
    @Serializable @SerialName("branch") data class Branch(val left: Node, val right: Node) : Node()
}
// Branch nests Node fields, each with their own "kind" tag.
val s = Json.encodeToString<Node>(Node.Branch(Node.Leaf(1), Node.Leaf(2)))

go deeper

for a junior

Understands you decode/encode through the base type to get polymorphism.

for a middle

Knows nested sealed fields are polymorphic and the discriminator key can collide with a property.

for a senior

Handles generic sealed serializers, static-type dispatch pitfalls, and per-hierarchy @JsonClassDiscriminator.

for a principal

Designs robust generic/nested wire schemas, prevents collisions by convention, and codifies base-type encoding to avoid silent round-trip breakage.

## 1. Dispatch is driven by the STATIC type you encode through Polymorphic discriminator injection happens only when the **declared/passed serializer is the base type's polymorphic serializer**. ```kotlin @Serializable sealed class Event { @Serializable @SerialName("click") data class Click(val x: Int): Event() } val e: Event = Event.Click(1) Json.encodeToString(e) // type known is Event -> {"type":"click","x":1} Json.encodeToString(Event.Click(1)) // static type Click -> {"x":1} NO discriminator! ``` If you encode the **concrete** type, you get the bare subtype with **no tag**, so decoding back through `Event` fails. Always encode through the **base** (`val e: Event = ...` or `Json.encodeToString<Event>(...)` / `Event.serializer()` / `PolymorphicSerializer(Event::class)`). ## 2. Generic sealed hierarchies ```kotlin @Serializable sealed class Result<out T> { @Serializable @SerialName("ok") data class Ok<T>(val value: T) : Result<T>() @Serializable @SerialName("err") data class Err(val message: String) : Result<Nothing>() } val s = Json.encodeToString(Result.serializer(Int.serializer()), Result.Ok(5)) // {"type":"ok","value":5} ``` - The plugin generates a **parameterized** `Result.serializer(tSerializer)`. You must supply the element serializer (the `reified` `encodeToString<Result<Int>>(...)` overload does this for you via `serializer()`). - The **discriminator tags only the Result subtype**; the payload `T` is serialized by **its own** serializer. If `T` is itself a polymorphic base, its value gets its own nested discriminator. ## 3. Nesting a sealed type as a property A sealed/polymorphic type used as a **field** is encoded polymorphically too: ```kotlin @Serializable data class Envelope(val payload: Event) // {"payload":{"type":"click","x":1}} ``` Each nested polymorphic value carries its **own** discriminator independently. Multiple levels nest cleanly. ## 4. Discriminator/field-name collisions The discriminator key (default `"type"`) is injected into the subtype object. If **any** subtype already has a real property named `type`, you get a **conflict** at init/encode time: ``` IllegalStateException/SerializationException: discriminator 'type' collides with a property ``` Fix: rename the property, or set a non-colliding key with `Json { classDiscriminator = "_t" }`, or use `@JsonClassDiscriminator("...")` on the base to scope it per-hierarchy. ## 5. Decoding base vs concrete - Decode through the **base** (`decodeFromString<Event>`) to use the discriminator and pick the subtype. - Decode through a **concrete** type only if the JSON has **no** discriminator (i.e., was encoded as that concrete type). Mixing the two is the classic round-trip bug. ## Key APIs/keywords `PolymorphicSerializer`, `Result.serializer(elementSerializer)`, `@SerialName`, `classDiscriminator`, `@JsonClassDiscriminator`, `reified serializer()`, `SerializationException`.

  • Why does encodeToString(concreteSubtype) sometimes omit the discriminator?
    Because the static type is the concrete subtype, not the polymorphic base, so the polymorphic serializer that injects the tag isn't selected.
  • A subtype has a field named 'type'. What happens and how do you fix it?
    It collides with the default discriminator key and throws. Rename the field or set classDiscriminator/@JsonClassDiscriminator to a non-colliding key.

Encoding a concrete subtype without the base is like mailing a parcel with the contents but no shipping label — the receiver can't tell what kind of box to open it as.

saying these in an interview costs you the question

  • Encoding the concrete subtype and expecting a discriminator
  • Forgetting to provide the element serializer for a generic sealed type
  • Naming a real property the same as the discriminator key
  • Assuming nested polymorphic fields don't each get their own tag

context