LogoPortfolio
Home
Projects
Articles
Certificates
More
Contact
Back to Articles
KotlinPolymorphismSealed ClassSerializationJSON

Polymorphism with kotlinx.serialization in Kotlin

Published on November 5, 2025

Kotlin’s polymorphism allows objects of different classes to be treated through a common base type (sealed classes, interfaces, or abstract classes). When serializing to formats like JSON, we need a way to record and restore the actual subtype of each object. The kotlinx.serialization library provides tools to handle polymorphic JSON: a special class discriminator field in the JSON, annotations like @SerialName, and runtime registration of subclasses. By combining these, you can encode a common base class (or interface) and correctly decode the appropriate subtype.

For example, marking a sealed class and its subclasses with @Serializable lets kotlinx.serialization generate the necessary serializers for each subtype. This ensures each subclass can be serialized and deserialized properly. In practice, polymorphic JSON usually includes a "type" (or custom key) that indicates which subclass to instantiate. Below we’ll see how to set this up for sealed classes (closed hierarchies) and open/abstract classes, and discuss some common pitfalls (like static fields in singleton objects).

Polymorphism with kotlinx.serialization in Kotlin (Thanks to Gemini)Polymorphism with kotlinx.serialization in Kotlin (Thanks to Gemini)

Sealed Classes: Closed Polymorphism

Sealed classes define a restricted hierarchy: all possible subclasses are known and declared in the same file. This makes them ideal for “closed” polymorphism. For example:

@Serializable
sealed class Vehicle {
    @Serializable
    @SerialName("car")
    data class Car(val make: String, val seats: Int) : Vehicle()

@Serializable
    @SerialName("bicycle")
    data class Bicycle(val type: String, val gears: Int) : Vehicle()
    @Serializable
    @SerialName("electric_scooter")
    data object ElectricScooter : Vehicle() {
        var batteryLevel: Int = 100
    }
}

In this example, Vehicle is a sealed class with three implementations. Two are data classes (Car, Bicycle) and one is a singleton object (ElectricScooter). All classes are annotated with @Serializable, which tells kotlinx.serialization to generate code for them. The @SerialName("...") on each subclass defines the string that will appear in the JSON as the type tag.

When serializing, we typically configure a Json instance with a class discriminator property. By default, kotlinx.serialization uses the key "type". For example:

val json = Json { classDiscriminator = "type" }
val myCar: Vehicle = Vehicle.Car(make = "Toyota", seats = 4)
val output = json.encodeToString(Vehicle.serializer(), myCar)
println(output)
// Example output: {"type":"car","make":"Toyota","seats":4}

Here the JSON contains "type":"car", matching the @SerialName("car") on the Car class. During deserialization, the library looks at the "type" field to decide which subclass to create. In code:

val jsonInput = """{"type":"bicycle","type":"mountain","gears":18}"""
val vehicle = json.decodeFromString<Vehicle>(jsonInput)
// vehicle is an instance of Vehicle.Bicycle(type="mountain", gears=18)

Key steps for sealed-class polymorphism:

  • Annotate all classes: Mark the base sealed class and every subclass with @Serializable.

  • Use @SerialName (optional but common): Give each subclass a unique name to appear in JSON.

  • Configure the JSON discriminant: The Json { classDiscriminator = "type" } setting tells kotlinx.serialization which JSON key holds the type name.

  • Encode/decode with the base serializer: For encoding, you can use encodeToString(Vehicle.serializer(), instance). For decoding, use decodeFromString(...). The generated Vehicle.serializer() understands to switch on the discriminator.

By default, the discriminator key is "type". If your JSON uses a different key (say "kind"), you can change it:

val json = Json { classDiscriminator = "kind" }

Or annotate the class with @JsonClassDiscriminator to override it at the class level.

Customizing the Discriminator

The class discriminator is simply the JSON field name used to carry the type identifier. By default it’s "type", but you can customize it globally:

  • In the Json builder: Json { classDiscriminator = "myTypeField" } sets a different key for all polymorphic classes.

  • With @JsonClassDiscriminator: On a sealed class or interface you can specify a custom discriminator just for that hierarchy.

For example:

@Serializable
@JsonClassDiscriminator("kind")
sealed class Action {
    @Serializable @SerialName("login") object Login : Action()
    @Serializable @SerialName("logout") object Logout : Action()
}

Now the JSON will use "kind":"login" instead of "type":"login". The @SerialName values still match the JSON, so if "kind":"login", it creates Action.Login.

Remember that @SerialName on subclasses must match the possible JSON values of the discriminator. In our examples, "car" matched @SerialName("car").

Open Polymorphism: Interfaces and Abstract Classes

When your hierarchy isn’t sealed (for example an interface or abstract base class), kotlinx.serialization cannot know all subclasses at compile time. This is “open” polymorphism. In this case, you must register each subclass in a SerializersModule at runtime. For example:

@Serializable
abstract class Article { abstract val title: String }

@Serializable @SerialName("news")
data class NewsArticle(override val title: String, val content: String) : Article()

@Serializable @SerialName("blog")
data class BlogPost(override val title: String, val author: String) : Article()

To serialize/deserialize these via the common Article type, set up a serializers module:

val module = SerializersModule {
    polymorphic(Article::class) {
        subclass(NewsArticle::class)
        subclass(BlogPost::class)
    }
}

val json = Json { serializersModule = module }

Now when you do json.encodeToString(Article.serializer(), articleInstance) or json.decodeFromString

(jsonString), kotlinx.serialization knows about the subclasses. Without registering, you would get a runtime error. We must explicitly register their polymorphic hierarchy at runtime because subclasses can be anywhere in the codebase.

In summary for open hierarchies:

  • Annotate each class with @Serializable (the base class or interface itself does not require @Serializable if it’s an interface, but you do need it on the subclasses).

  • Register subclasses in a SerializersModule using polymorphic(Base::class) { subclass(Sub::class) }.

  • Use the configured Json with this module for encode/decode.

Optionally, if you ever need to work with Any or an interface where you don’t know the type at compile time, you can register them similarly. Just remember that open polymorphism relies on runtime registration, whereas sealed classes handle polymorphism more automatically.

Advanced: Content-Based Selection

In some rare cases, your JSON might not include a type discriminator at all. Maybe the fields are distinct enough that you can tell which subclass it is by the presence of certain properties. In such cases, kotlinx.serialization offers the JsonContentPolymorphicSerializer, which lets you write custom logic based on the JSON content. For example:

object MsgSerializer : JsonContentPolymorphicSerializer<Message>(Message::class) {
    override fun selectDeserializer(element: JsonElement): DeserializationStrategy<Message> {
        return when {
            "errorCode" in element.jsonObject -> ErrorMessage.serializer()
            "userId" in element.jsonObject -> UserMessage.serializer()
            else -> throw SerializationException("Unknown message type")
        }
    }
}

@Serializable(MsgSerializer::class)
sealed class Message { /* ... */ }

This inspects the JSON and picks a serializer (and subtype) based on which fields appear. It’s an advanced technique but shows the flexibility of kotlinx.serialization when standard discriminators aren’t available.

Singleton Objects and Static Properties

One important gotcha: Kotlin’s object declarations are singletons (only one instance exists at runtime). This means any property inside an object is effectively global/static state. Such properties are not part of the serialized JSON; they live in the singleton instance.

For example, in the ElectricScooter object above, batteryLevel is a mutable property on the singleton. Suppose we do:

// Before serialization
println(Vehicle.ElectricScooter.batteryLevel) // e.g. prints 100

// Change it at runtime
Vehicle.ElectricScooter.batteryLevel = 50

// Serialize and deserialize (no field for batteryLevel in JSON):
val jsonString = json.encodeToString<Vehicle>(Vehicle.ElectricScooter)
json.decodeFromString<Vehicle>(jsonString)

// After deserialization
println(Vehicle.ElectricScooter.batteryLevel) // STILL prints 50

The batteryLevel remains 50 even after the round-trip, because the JSON had no batteryLevel field and the singleton’s state was never reset. In other words, changing an object’s property persists across serialization. This is simply because serialization only cares about annotated properties; a var inside an object is not serialized unless you explicitly include it in the data model. Since ElectricScooter.batteryLevel is not encoded, deserializing Vehicle won’t overwrite it.

The Kotlin language documentation emphasizes that an object declaration has a single instance. As a result, any constants (const val) or vars in the object act like static globals. They survive serialize/deserialize cycles unchanged. So be cautious: if you rely on such state, serialization won’t magically reset it or transport it.

Summary

Kotlinx.serialization provides robust support for polymorphism:

  • Sealed classes offer “closed” polymorphism with minimal setup. Annotate each class with @Serializable, give each subtype a @SerialName, and use a Json with a class discriminator (default "type"). Then encoding/decoding a sealed base class will automatically preserve the actual subtype.

  • Open hierarchies (abstract classes/interfaces) require explicit registration. Use a SerializersModule and polymorphic(Base::class) { subclass(Sub::class) } to tell kotlinx.serialization about each subtype. Otherwise the library cannot instantiate unknown subclasses.

  • Class discriminator: By default, kotlinx.serialization uses a field named "type" in JSON. You can customize it via Json { classDiscriminator = "..." } or @JsonClassDiscriminator.

  • Static fields in objects are not serialized. Any object (including data object) is a singleton, so changing its properties affects the global instance. Serialization/deserialization will not change that static state.

  • Advanced use: If your JSON lacks a type tag, consider a JsonContentPolymorphicSerializer to inspect fields and select the correct subclass.

By following these patterns, you can effectively serialize and deserialize polymorphic data structures in Kotlin in a type-safe way. The library’s support for sealed classes makes closed hierarchies straightforward, while SerializersModule enables flexible handling of dynamic class hierarchies. With careful setup (and awareness of singletons), your Kotlin application can robustly send and receive polymorphic JSON.

About

Professional portfolio showcasing my work, articles, and achievements.

Quick Links

  • Projects
  • Articles
  • Certificates
  • Contact

Connect

GitHubGitHubLinkedInMediumMedium

Subscribe

© 2026 Portfolio. All rights reserved.