skip to content

Sealed-Class Polymorphic Serialization

A sealed hierarchy serializes polymorphically for free, with subclasses registered automatically and a type discriminator in the output. Open hierarchies get none of that and need explicit registration, which is exactly the comparison interviewers want.

part ofKotlinoverview, primer and where to startread it →
on this pageshow

questions

5

With kotlinx.serialization, what happens when you mark a sealed class @Serializable and serialize one of its subclasses to JSON? What does the output look like?

level: juniorimportance: must knowfreq 70%

answer

  1. sealed => compiler auto-registers subclasses
  2. default discriminator key = "type"
  3. discriminator value = serial name (@SerialName)
  4. annotate base AND every subclass @Serializable
  5. decode via base serializer

basics

~10 s

kotlinx.serialization adds a special field (by default named "type") to the JSON that records which subclass it is. When reading back, that field tells the library which subclass to build.

solid answer

~40 s

If you annotate a sealed class with @Serializable and annotate each subclass with @Serializable, kotlinx.serialization treats the hierarchy as polymorphic. Because the compiler plugin knows all subclasses of a sealed class at compile time, it auto-registers them — no manual SerializersModule needed. Serializing a subclass produces a JSON object containing a class-discriminator field (default key "type") whose value is the subclass's fully-qualified serial name, plus the subclass's own properties. On decode, the library reads the discriminator first, selects the matching subclass serializer, and deserializes the rest. The discriminator key is configurable via Json { classDiscriminator = "..." } and the value via @SerialName on each subclass. This auto-registration is the key advantage over open/abstract polymorphism, which requires explicit registration.

code

kotlin · 11 lines
kotlin
@Serializable
sealed class Shape {
    @Serializable @SerialName("circle")
    data class Circle(val r: Double) : Shape()
    @Serializable @SerialName("rect")
    data class Rect(val w: Double, val h: Double) : Shape()
}

val s: Shape = Shape.Circle(2.0)
println(Json.encodeToString(s)) // {"type":"circle","r":2.0}
val back = Json.decodeFromString<Shape>("""{"type":"rect","w":1.0,"h":2.0}""")

go deeper

for a junior

Knows sealed + @Serializable on base and subclasses produces a 'type' field automatically.

for a middle

Can change the discriminator key/value and explain decode lookup by discriminator.

for a senior

Explains why sealed enables compile-time auto-registration and contrasts with open hierarchies.

for a principal

Frames discriminator naming as a wire-contract decision (stable @SerialName values, not class names) for forward/backward compatibility.

## What polymorphic serialization means "Polymorphic" here means: you have a base type (a sealed class or interface) and several concrete subtypes, and you want to serialize/deserialize values through the **base** type while preserving **which concrete subtype** each value actually is. JSON has no built-in notion of "class", so kotlinx.serialization stores the type identity in a **class discriminator** field. ## Why sealed classes are special A `sealed` class/interface restricts its subclasses to the same compilation unit (module/package), so the **compiler knows the complete set of subtypes**. The kotlinx.serialization compiler plugin uses this to **auto-register** every subclass — you do not write any `SerializersModule`. ## What you must annotate - The sealed base type: `@Serializable` - **Every** concrete subclass: `@Serializable` ```kotlin import kotlinx.serialization.* import kotlinx.serialization.json.Json @Serializable sealed class Event { @Serializable data class Click(val x: Int, val y: Int) : Event() @Serializable data class Key(val code: Int) : Event() } val json = Json.encodeToString(Event.serializer(), Event.Click(3, 4)) // {"type":"Event.Click","x":3,"y":4} ``` ## The discriminator - **Key**: default `"type"`. Change globally with `Json { classDiscriminator = "_t" }`. - **Value**: the **serial name** of the subclass. By default this is the fully-qualified class name; override per-subclass with `@SerialName("click")`. ```kotlin @Serializable @SerialName("click") data class Click(val x: Int, val y: Int) : Event() // -> {"type":"click","x":3,"y":4} ``` ## Decoding Decode through the **base** serializer (`Event.serializer()`). The library reads the discriminator first, looks up the registered subclass, and builds it. If the discriminator value matches no registered subtype, it throws `SerializationException`. ## Key APIs/keywords `@Serializable`, `@SerialName`, `sealed`, `Json.encodeToString`, `Json { classDiscriminator = ... }`, `SerializationException`.

  • How do you change the discriminator value without changing the Kotlin class name?
    Put @SerialName("...") on the subclass; the serial name becomes the discriminator value.
  • What if you forget @Serializable on one subclass?
    Compilation/serialization fails for that subclass; the plugin can't generate a serializer, so it can't be auto-registered.

The discriminator is like a shipping label on a box: the contents are the fields, the label tells the receiver which kind of box to unpack it as.

saying these in an interview costs you the question

  • Claiming you must always write a SerializersModule for sealed classes
  • Saying the discriminator is stored as a JSON array element rather than an object field
  • Thinking only the base class needs @Serializable
  • Confusing the discriminator KEY (default 'type') with its VALUE (the serial name)

context

open as a page

Why does a @Serializable sealed hierarchy work out of the box, while making an open/abstract base polymorphic requires a SerializersModule? Walk through registering the open case.

level: middleimportance: must knowfreq 60%

basics

~20 s

For sealed types the compiler knows every subclass, so it registers them for you. For open or abstract types, subclasses can live anywhere, so you must list them yourself in a SerializersModule with polymorphic { subclass(...) }.

open as a page

You ship a sealed @Serializable hierarchy as a public JSON API. A teammate renames a subclass or refactors its package. Why might that break existing clients, and how do you prevent it?

level: middleimportance: should knowfreq 45%

basics

~20 s

By default the discriminator value is the class's full name. Renaming the class or moving its package changes that value, so old JSON no longer matches. Pin a stable value with @SerialName on every subclass.

open as a page

kotlinx.serialization supports more than one way of laying out polymorphic data on the wire. Explain ARRAY_WRAPPED vs the discriminator object mode, and when you must use which.

level: seniorimportance: should knowfreq 35%

basics

~20 s

Normally the type name is a field inside the object (discriminator mode). But that only works when the value is a JSON object. For non-object values (like a primitive), the library wraps it as a two-element array [typeName, value]. You can also force array mode.

open as a page

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%

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.

open as a page