This is the fourth chapter of the Kotlin Serialization Guide. In this chapter we'll see how Kotlin Serialization deals with polymorphic class hierarchies.
Table of contents
Let us start with basic introduction to polymorphism.
Kotlin Serialization is fully static with respect to types by default. The structure of encoded objects is determined by compile-time types of objects. Let's examine this aspect in more detail and learn how to serialize polymorphic data structures, where the type of data is determined at runtime.
To show the static nature of Kotlin Serialization let us make the following setup. An open class Project
has just the name
property, while its derived class OwnedProject
adds an owner
property. In the below example, we serialize data
variable with a static type of Project
that is initialized with an instance of OwnedProject
at runtime.
@Serializable open class Project(val name: String) class OwnedProject(name: String, val owner: String) : Project(name) fun main() { val data: Project = OwnedProject("kotlinx.coroutines", "kotlin") println(Json.encodeToString(data)) }
You can get the full code here.
Despite the runtime type of OwnedProject
, only the Project
class properties are getting serialized.
{"name":"kotlinx.coroutines"}
Let's change the compile-time type of data
to OwnedProject
.
@Serializable open class Project(val name: String) class OwnedProject(name: String, val owner: String) : Project(name) fun main() { val data = OwnedProject("kotlinx.coroutines", "kotlin") println(Json.encodeToString(data)) }
You can get the full code here.
We get an error, because the OwnedProject
class is not serializable.
Exception in thread "main" kotlinx.serialization.SerializationException: Serializer for class 'OwnedProject' is not found. Please ensure that class is marked as '@Serializable' and that the serialization compiler plugin is applied.
We cannot simply mark OwnedProject
from the previous example as @Serializable
. It does not compile, running into the constructor properties requirement. To make hierarchy of classes serializable, the properties in the parent class have to be marked abstract
, making the Project
class abstract
, too.
@Serializable abstract class Project { abstract val name: String } class OwnedProject(override val name: String, val owner: String) : Project() fun main() { val data: Project = OwnedProject("kotlinx.coroutines", "kotlin") println(Json.encodeToString(data)) }
You can get the full code here.
This is close to the best design for a serializable hierarchy of classes, but running it produces the following error:
Exception in thread "main" kotlinx.serialization.SerializationException: Serializer for subclass 'OwnedProject' is not found in the polymorphic scope of 'Project'. Check if class with serial name 'OwnedProject' exists and serializer is registered in a corresponding SerializersModule. To be registered automatically, class 'OwnedProject' has to be '@Serializable', and the base class 'Project' has to be sealed and '@Serializable'.
The most straightforward way to use serialization with a polymorphic hierarchy is to mark the base class sealed
. All subclasses of a sealed class must be explicitly marked as @Serializable
.
@Serializable sealed class Project { abstract val name: String } @Serializable class OwnedProject(override val name: String, val owner: String) : Project() fun main() { val data: Project = OwnedProject("kotlinx.coroutines", "kotlin") println(Json.encodeToString(data)) // Serializing data of compile-time type Project }
You can get the full code here.
Now we can see a default way to represent polymorphism in JSON. A type
key is added to the resulting JSON object as a discriminator.
{"type":"example.examplePoly04.OwnedProject","name":"kotlinx.coroutines","owner":"kotlin"}
Pay attention to the small, but very important detail in the above example that is related to Static types: the val data
property has a compile-time type of Project
, even though its run-time type is OwnedProject
. When serializing polymorphic class hierarchies you must ensure that the compile-time type of the serialized object is a polymorphic one, not a concrete one.
Let us see what happens if the example is slightly changed, so that the compile-time of the object that is being serialized is OwnedProject
(the same as its run-time type).
@Serializable sealed class Project { abstract val name: String } @Serializable class OwnedProject(override val name: String, val owner: String) : Project() fun main() { val data = OwnedProject("kotlinx.coroutines", "kotlin") // data: OwnedProject here println(Json.encodeToString(data)) // Serializing data of compile-time type OwnedProject }
You can get the full code here.
The type of OwnedProject
is concrete and is not polymorphic, thus the type
discriminator property is not emitted into the resulting JSON.
{"name":"kotlinx.coroutines","owner":"kotlin"}
In general, Kotlin Serialization is designed to work correctly only when the compile-time type used during serialization is the same one as the compile-time type used during deserialization. You can always specify the type explicitly when calling serialization functions. The previous example can be corrected to use Project
type for serialization by calling Json.encodeToString<Project>(data)
.
A value of the type
key is a fully qualified class name by default. We can put SerialName annotation onto the corresponding class to change it.
@Serializable sealed class Project { abstract val name: String } @Serializable @SerialName("owned") class OwnedProject(override val name: String, val owner: String) : Project() fun main() { val data: Project = OwnedProject("kotlinx.coroutines", "kotlin") println(Json.encodeToString(data)) }
You can get the full code here.
This way we can have a stable serial name that is not affected by the class's name in the source code.
{"type":"owned","name":"kotlinx.coroutines","owner":"kotlin"}
In addition to that, JSON can be configured to use a different key name for the class discriminator. You can find an example in the Class discriminator for polymorphism section.
A base class in a sealed hierarchy can have properties with backing fields.
@Serializable sealed class Project { abstract val name: String var status = "open" } @Serializable @SerialName("owned") class OwnedProject(override val name: String, val owner: String) : Project() fun main() { val json = Json { encodeDefaults = true } // "status" will be skipped otherwise val data: Project = OwnedProject("kotlinx.coroutines", "kotlin") println(json.encodeToString(data)) }
You can get the full code here.
The properties of the superclass are serialized before the properties of the subclass.
{"type":"owned","status":"open","name":"kotlinx.coroutines","owner":"kotlin"}
Sealed hierarchies can have objects as their subclasses and they also need to be marked as @Serializable
. Let's take a different example with a hierarchy of Response
classes.
@Serializable sealed class Response @Serializable object EmptyResponse : Response() @Serializable class TextResponse(val text: String) : Response()
Let us serialize a list of different responses.
fun main() { val list = listOf(EmptyResponse, TextResponse("OK")) println(Json.encodeToString(list)) }
You can get the full code here.
An object serializes as an empty class, also using its fully-qualified class name as type by default:
[{"type":"example.examplePoly08.EmptyResponse"},{"type":"example.examplePoly08.TextResponse","text":"OK"}]
Even if object has properties, they are not serialized.
Serialization can work with arbitrary open
classes or abstract
classes. However, since this kind of polymorphism is open, there is a possibility that subclasses are defined anywhere in the source code, even in other modules, the list of subclasses that are serialized cannot be determined at compile-time and must be explicitly registered at runtime.
Let us start with the code from the Designing serializable hierarchy section. To make it work with serialization without making it sealed
, we have to define a SerializersModule using the SerializersModule {} builder function. In the module the base class is specified in the polymorphic builder and each subclass is registered with the subclass function. Now, a custom JSON configuration can be instantiated with this module and used for serialization.
Details on custom JSON configurations can be found in the JSON configuration section.
val module = SerializersModule { polymorphic(Project::class) { subclass(OwnedProject::class) } } val format = Json { serializersModule = module } @Serializable abstract class Project { abstract val name: String } @Serializable @SerialName("owned") class OwnedProject(override val name: String, val owner: String) : Project() fun main() { val data: Project = OwnedProject("kotlinx.coroutines", "kotlin") println(format.encodeToString(data)) }
You can get the full code here.
This additional configuration makes our code work just as it worked with a sealed class in the Sealed classes section, but here subclasses can be spread arbitrarily throughout the code.
{"type":"owned","name":"kotlinx.coroutines","owner":"kotlin"}
Please note that this example works only on JVM because of
serializer
function restrictions. For JS and Native, explicit serializer should be used:format.encodeToString(PolymorphicSerializer(Project::class), data)
You can keep track of this issue here.
We can update the previous example and turn Project
superclass into an interface. However, we cannot mark an interface itself as @Serializable
. No problem. Interfaces cannot have instances by themselves. Interfaces can only be represented by instances of their derived classes. Interfaces are used in the Kotlin language to enable polymorphism, so all interfaces are considered to be implicitly serializable with the PolymorphicSerializer strategy. We just need to mark their implementing classes as @Serializable
and register them.
interface Project { val name: String } @Serializable @SerialName("owned") class OwnedProject(override val name: String, val owner: String) : Project
Now if we declare data
with the type of Project
we can simply call format.encodeToString
as before.
fun main() { val data: Project = OwnedProject("kotlinx.coroutines", "kotlin") println(format.encodeToString(data)) }
You can get the full code here.
{"type":"owned","name":"kotlinx.coroutines","owner":"kotlin"}
Note: On Kotlin/Native, you should use
format.encodeToString(PolymorphicSerializer(Project::class), data))
instead due to limited reflection capabilities.
Continuing the previous example, let us see what happens if we use Project
interface as a property in some other serializable class. Interfaces are implicitly polymorphic, so we can just declare a property of an interface type.
@Serializable class Data(val project: Project) // Project is an interface fun main() { val data = Data(OwnedProject("kotlinx.coroutines", "kotlin")) println(format.encodeToString(data)) }
You can get the full code here.
As long as we've registered the actual subtype of the interface that is being serialized in the SerializersModule of our format
, we get it working at runtime.
{"project":{"type":"owned","name":"kotlinx.coroutines","owner":"kotlin"}}
During serialization of a polymorphic class the root type of the polymorphic hierarchy (Project
in our example) is determined statically. Let us take the example with the serializable abstract class Project
, but change the main
function to declare data
as having a type of Any
:
fun main() { val data: Any = OwnedProject("kotlinx.coroutines", "kotlin") println(format.encodeToString(data)) }
You can get the full code here.
We get the exception.
Exception in thread "main" kotlinx.serialization.SerializationException: Serializer for class 'Any' is not found. Please ensure that class is marked as '@Serializable' and that the serialization compiler plugin is applied.
We have to register classes for polymorphic serialization with respect for the corresponding static type we use in the source code. First of all, we change our module to register a subclass of Any
:
val module = SerializersModule { polymorphic(Any::class) { subclass(OwnedProject::class) } }
Then we can try to serialize the variable of type Any
:
fun main() { val data: Any = OwnedProject("kotlinx.coroutines", "kotlin") println(format.encodeToString(data)) }
You can get the full code here.
However, Any
is a class and it is not serializable:
Exception in thread "main" kotlinx.serialization.SerializationException: Serializer for class 'Any' is not found. Please ensure that class is marked as '@Serializable' and that the serialization compiler plugin is applied.
We must to explicitly pass an instance of PolymorphicSerializer for the base class Any
as the first parameter to the encodeToString function.
fun main() { val data: Any = OwnedProject("kotlinx.coroutines", "kotlin") println(format.encodeToString(PolymorphicSerializer(Any::class), data)) }
You can get the full code here.
With the explicit serializer it works as before.
{"type":"owned","name":"kotlinx.coroutines","owner":"kotlin"}
The property of an interface type is implicitly considered polymorphic, since interfaces are all about runtime polymorphism. However, Kotlin Serialization does not compile a serializable class with a property of a non-serializable class type. If we have a property of Any
class or other non-serializable class, then we must explicitly provide its serialization strategy via the @Serializable
annotation as we saw in the Specifying serializer on a property section. To specify a polymorphic serialization strategy of a property, the special-purpose @Polymorphic
annotation is used.
@Serializable class Data( @Polymorphic // the code does not compile without it val project: Any ) fun main() { val data = Data(OwnedProject("kotlinx.coroutines", "kotlin")) println(format.encodeToString(data)) }
You can get the full code here.
When the same class gets serialized as a value of properties with different compile-time type from the list of its superclasses, we must register it in the SerializersModule for each of its superclasses separately. It is convenient to extract registration of all the subclasses into a separate function and use it for each superclass. You can use the following template to write it.
val module = SerializersModule { fun PolymorphicModuleBuilder<Project>.registerProjectSubclasses() { subclass(OwnedProject::class) } polymorphic(Any::class) { registerProjectSubclasses() } polymorphic(Project::class) { registerProjectSubclasses() } }
You can get the full code here.
Generic subtypes for a serializable class require a special handling. Consider the following hierarchy.
@Serializable abstract class Response<out T> @Serializable @SerialName("OkResponse") data class OkResponse<out T>(val data: T) : Response<T>()
Kotlin Serialization does not have a builtin strategy to represent the actually provided argument type for the type parameter T
when serializing a property of the polymorphic type OkResponse<T>
. We have to provide this strategy explicitly when defining the serializers module for the Response
. In the below example we use OkResponse.serializer(...)
to retrieve the Plugin-generated generic serializer of the OkResponse
class and instantiate it with the PolymorphicSerializer instance with Any
class as its base. This way, we can serialize an instance of OkResponse
with any data
property that was polymorphically registered as a subtype of Any
.
val responseModule = SerializersModule { polymorphic(Response::class) { subclass(OkResponse.serializer(PolymorphicSerializer(Any::class))) } }
When the application grows in size and splits into source code modules, it may become inconvenient to store all class hierarchies in one serializers module. Let us add a library with the Project
hierarchy to the code from the previous section.
val projectModule = SerializersModule { fun PolymorphicModuleBuilder<Project>.registerProjectSubclasses() { subclass(OwnedProject::class) } polymorphic(Any::class) { registerProjectSubclasses() } polymorphic(Project::class) { registerProjectSubclasses() } }
We can compose those two modules together using the plus operator to merge them, so that we can use them both in the same Json format instance.
You can also use the include function in the SerializersModule {} DSL.
val format = Json { serializersModule = projectModule + responseModule }
Now classes from both hierarchies can be serialized together and deserialized together.
fun main() { // both Response and Project are abstract and their concrete subtypes are being serialized val data: Response<Project> = OkResponse(OwnedProject("kotlinx.serialization", "kotlin")) val string = format.encodeToString(data) println(string) println(format.decodeFromString<Response<Project>>(string)) }
You can get the full code here.
The JSON that is being produced is deeply polymorphic.
{"type":"OkResponse","data":{"type":"OwnedProject","name":"kotlinx.serialization","owner":"kotlin"}} OkResponse(data=OwnedProject(name=kotlinx.serialization, owner=kotlin))
If you're writing a library or shared module with an abstract class and some implementations of it, you can expose your own serializers module for your clients to use so that a client can combine your module with their modules.
What happens when we deserialize a subclass that was not registered?
fun main() { println(format.decodeFromString<Project>(""" {"type":"unknown","name":"example"} """)) }
You can get the full code here.
We get the following exception.
Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 0: Serializer for subclass 'unknown' is not found in the polymorphic scope of 'Project' at path: $ Check if class with serial name 'unknown' exists and serializer is registered in a corresponding SerializersModule.
When reading a flexible input we might want to provide some default behavior in this case. For example, we can have a BasicProject
subtype to represent all kinds of unknown Project
subtypes.
@Serializable abstract class Project { abstract val name: String } @Serializable data class BasicProject(override val name: String, val type: String): Project() @Serializable @SerialName("OwnedProject") data class OwnedProject(override val name: String, val owner: String) : Project()
We register a default deserializer handler using the defaultDeserializer
function in the polymorphic { ... }
DSL that defines a strategy which maps the type
string from the input to the deserialization strategy. In the below example we don't use the type, but always return the Plugin-generated serializer of the BasicProject
class.
val module = SerializersModule { polymorphic(Project::class) { subclass(OwnedProject::class) defaultDeserializer { BasicProject.serializer() } } }
Using this module we can now deserialize both instances of the registered OwnedProject
and any unregistered one.
val format = Json { serializersModule = module } fun main() { println(format.decodeFromString<List<Project>>(""" [ {"type":"unknown","name":"example"}, {"type":"OwnedProject","name":"kotlinx.serialization","owner":"kotlin"} ] """)) }
You can get the full code here.
Notice, how BasicProject
had also captured the specified type key in its type
property.
[BasicProject(name=example, type=unknown), OwnedProject(name=kotlinx.serialization, owner=kotlin)]
We used a plugin-generated serializer as a default serializer, implying that the structure of the "unknown" data is known in advance. In a real-world API it's rarely the case. For that purpose a custom, less-structured serializer is needed. You will see the example of such serializer in the future section on Maintaining custom JSON attributes.
Sometimes you need to dynamically choose which serializer to use for a polymorphic type based on the instance, for example if you don't have access to the full type hierarchy, or if it changes a lot. For this situation, you can register a default serializer.
interface Animal { } interface Cat : Animal { val catType: String } interface Dog : Animal { val dogType: String } private class CatImpl : Cat { override val catType: String = "Tabby" } private class DogImpl : Dog { override val dogType: String = "Husky" } object AnimalProvider { fun createCat(): Cat = CatImpl() fun createDog(): Dog = DogImpl() }
We register a default serializer handler using the polymorphicDefaultSerializer
function in the SerializersModule { ... }
DSL that defines a strategy which takes an instance of the base class and provides a serialization strategy. In the below example we use a when
block to check the type of the instance, without ever having to refer to the private implementation classes.
val module = SerializersModule { polymorphicDefaultSerializer(Animal::class) { instance -> @Suppress("UNCHECKED_CAST") when (instance) { is Cat -> CatSerializer as SerializationStrategy<Animal> is Dog -> DogSerializer as SerializationStrategy<Animal> else -> null } } } object CatSerializer : SerializationStrategy<Cat> { override val descriptor = buildClassSerialDescriptor("Cat") { element<String>("catType") } override fun serialize(encoder: Encoder, value: Cat) { encoder.encodeStructure(descriptor) { encodeStringElement(descriptor, 0, value.catType) } } } object DogSerializer : SerializationStrategy<Dog> { override val descriptor = buildClassSerialDescriptor("Dog") { element<String>("dogType") } override fun serialize(encoder: Encoder, value: Dog) { encoder.encodeStructure(descriptor) { encodeStringElement(descriptor, 0, value.dogType) } } }
Using this module we can now serialize instances of Cat
and Dog
.
val format = Json { serializersModule = module } fun main() { println(format.encodeToString<Animal>(AnimalProvider.createCat())) }
You can get the full code here
{"type":"Cat","catType":"Tabby"}
The next chapter covers JSON features.