Annotation for applying @Serializable to nested classes
If I want to define a class hierarchy of small serializable classes, the @Serializable annotation can become painfully redundant. This can make the code at worst twice as long, and reduces the readability significantly.
Before writing this, I considered some previous requests. These requests were similar, but none quite captured the idea this way, so I thought it would be worth it to create a new issue.
The important details for my proposal are:
- If our computer screen is capable of fitting, say, 10 class definitions on it, it would be good to just see a single annotation saying "all of these classes are serializable"
- This proposal does not change the current behavior of
@Serializable, it is backwards compatible. - This proposal is for a new annotation (or possible new target site of
@Serializable). It simple reads as "all of these classes you see here will have generated serializers". - This annotation, in its simplest form, does not need to have any arguments.
Here is some code the exemplifies the main issue. This code is made twice as long because of all of the Serializable annotations.
@Serializable
class Zoo(val animals: Set<Animal>)
@Serializable
sealed interface Animal
@Serializable
data class Penguin(val name: String, val age: Int, val isHappyFeet: Boolean): Animal
@Serializable
data class Lion(val name: String, val maneLength: Int): Animal
@Serializable
data class Elephant(val name: String, val trunkLength: Int, val weight: Int): Animal
@Serializable
data class Monkey(val name: String, val favoriteFood: String): Animal
@Serializable
data class Giraffe(val name: String, val neckLength: Int): Animal
@Serializable
data class Tiger(val name: String, val stripeCount: Int): Animal
@Serializable
data class Zebra(val name: String, val stripePattern: String): Animal
@Serializable
data class Parrot(val name: String, val canTalk: Boolean): Animal
@Serializable
data class Snake(val name: String, val length: Int): Animal
@Serializable
data class Bear(val name: String, val type: String): Animal
This propsal is for an annotation such as:
@AllSerializable
object ZooClasses {
class Zoo(val animals: Set<Animal>)
sealed interface Animal
data class Penguin(val name: String, val age: Int, val isHappyFeet: Boolean): Animal
data class Lion(val name: String, val maneLength: Int): Animal
data class Elephant(val name: String, val trunkLength: Int, val weight: Int): Animal
data class Monkey(val name: String, val favoriteFood: String): Animal
data class Giraffe(val name: String, val neckLength: Int): Animal
data class Tiger(val name: String, val stripeCount: Int): Animal
data class Zebra(val name: String, val stripePattern: String): Animal
data class Parrot(val name: String, val canTalk: Boolean): Animal
data class Snake(val name: String, val length: Int): Animal
data class Bear(val name: String, val type: String): Animal
}
My idea is that ZooClasses itself is not made serializable from @AllSerializable, but that detail can be changed.
One workaround to reduce the number of lines is to put @Serializable on the same line as the definition like:
@Serializable data class Bear(val name: String, val type: String): Animal
However, has a couple issues:
- It will usually break formatter rules, so these formatter rules would have to be suppressed on the file
- It adds significant width to each line, makes them less readable, and reduces how many properties can fit on one line
For my example, I used single-line class definitions to highlight the issue. However, even for more typical class definitons that are multiple lines, this issue still matters.
I think this might be something that is possible to implement without many issues. When the compiler plugin sees @AllSerializable, it behaves exactly as if each nested class had @Serializable.
If a class inside ZooClasses has @Serializable with no arguments, the behavior is the same. There could be a warning inidicating the redundancy.
If a class inside ZooClasses has @Serializable with a custom serializer, the custom serializer is generated like normal, and the plugin does not do anything special (it doesn't generate two serializers)
This would also work with objects and enums, following the same logic.
There are no special rules or requirements regarding sealed classes/interfaces. Using AllSerializable would behave exactly like putting Serializable on each individual class or object inside the group.
Whether a class is inner or not doesn't matter. The only thing that matters is class nesting.
@file:Serializable or @file:AllSerializable could possibly be implemented as part of this. However, @file annotations are easy to miss since they are above the import statements. I think including @file annotations could make this request more controversial, so I propose not to include them (at least for now).
Here is how this request is different from similar previous requests:
- https://github.com/Kotlin/kotlinx.serialization/issues/1808
This request was for plugin-wide automatic serializers. It was closed without fixing based on conerns about readability, security, and performance and I think none of the issues mentioned there apply here
- https://github.com/Kotlin/kotlinx.serialization/issues/1807
This request was closed for the same reasons as 1808, which I think are resolved here
- https://github.com/Kotlin/kotlinx.serialization/issues/2572
This request is for applying @Serializable based on sealed class hierarchy. Although the motivations are overlapping, the implementation in my proposal is different. My proposal is about applying an annotation based on being close together in the file and nothing to do with class hierarchies. I think that all of the issues mentioned by @sandwwraith in his comment for that issue are addressed and solved in my proposal:
it would require the plugin to analyze every single class in the project and its superclasses
This will not be an issue here, because this feature is based on an annotation. The compiler plugin will have to handle each annotation by checking nested classes, but I think that seems very small compared to scanning the whole module.
if my intention to have only some of the inheritors serializable, how can I opt-out?
Since this is just based on being a nested class in the annotated group, opting-out is as simple as moving the class you don't want to serialize outside of the class or object annotated with @AllSerializable.
it would not be backward-compatible
This proposal should be backwards compatible
Note also that @sandwwraith mentioned the idea of
@SerializableSubclasses for nested classes or classes in the same file
in that comment. I wasn't sure exactly what that meant, but to be clear this proposal is for an annotation that doesn't care whatsover about subclasses. I think that would help make this annotation be more simple and readable.
Just to +1 this and add that it's even more unreadable currently when using @JsonClassDiscriminator where you have to add @SerialName to each subclass (usually nested), and then on top of that add the @Serializable.
Even worse if you forget to add it there's no compile time warning or error, it only breaks at runtime.
Yes, this ticket is a good description of what I meant when discussing applying @Serializable for nested classes. I think this would work well for both sealed and non-sealed classes. I think it is possible to implement with the new Kotlin 2.0 frontend so we can add this to our roadmap.
The only controversial thing I see here is that example has @AllSerializable object ZooClasses. There's little benefit of making ZooClasses serializable itself, and Kotlin does not currently have namespaces to replace the object. However, in the case of @AllSerializable/@SerializableSubClasses sealed interface Animal, it is desirable that Animal is also serializable. So I lean more towards a design where the annotated class itself would also be serializable, and we can rewrite the example to:
@Serializable
class Zoo(val animals: Set<Animal>)
@AllSerializable
sealed class Animal {
data class Penguin(val name: String, val age: Int, val isHappyFeet: Boolean): Animal
data class Lion(val name: String, val maneLength: Int): Animal
data class Elephant(val name: String, val trunkLength: Int, val weight: Int): Animal
data class Monkey(val name: String, val favoriteFood: String): Animal
data class Giraffe(val name: String, val neckLength: Int): Animal
data class Tiger(val name: String, val stripeCount: Int): Animal
data class Zebra(val name: String, val stripePattern: String): Animal
data class Parrot(val name: String, val canTalk: Boolean): Animal
data class Snake(val name: String, val length: Int): Animal
data class Bear(val name: String, val type: String): Animal
}