sealed-enum: Replacing Enums in Kotlin
By: Alex Vanyo
Tags: Sealed Classes, Kotlin, Android, Enum, Engineering

Sealed classes are one of Kotlin’s powerful tools to express hierarchies and precisely enumerate the form that data takes. With the when keyword, working with sealed classes can be simple and safe, with no need for an annoying else branch. When initially presented, especially for someone coming from a Java background, they appear to be even more powerful enums; indeed, the official Kotlin documentation¹ states that sealed classes

are in a sense, an extension of enum classes.

Ever since I learned about sealed classes, I’ve used them constantly. Nesting sealed classes allows expressing complex state in an incredibly powerful way, and serializing and deserializing them is effortless with kotlinx.serialization. Generic sealed classes allow convenient handling for common wrappers, like State<T> or Result<T> .

Yet with everything that sealed classes can do, I still found myself writing the following code:

For how I was using ScrollingBehavior , I could have just as easily made it a sealed class:

With when , the code to consume the enum class and the sealed class would be pretty much identical. So why do we need still need enums at all? A sealed class of only objects is strictly a more powerful enum: they are both a static, finite enumeration of something. Just like an enum, the sealed class can have constructor arguments or abstract values, and implement interfaces. However, sealed classes are much more flexible than enum classes: they can extend other classes, have generic type arguments, or be part of a nested sealed class hierarchy.

In the scenario above, I didn’t need to deal with the full list of ScrollingBehavior s. If I had, the enum class would have been the more convenient approach due to the existence of ScrollingBehavior.values() . Accomplishing the same for sealed classes is not as simple. The only built-in way is with KClass.sealedSubclasses which works, but requires Kotlin reflection, which can be unacceptably slow at runtime. The alternative is maintaining the list of objects manually², which also works, but is error-prone boilerplate — but boilerplate can be automated away!

Furthermore, there’s an opportunity here for improved generic handling, since dealing with enums generically is painful. enumValuesOf<T>() works if it is possible to access the enum type in a reified way, but if it isn’t, the main option is to pass through the Class of the enum for Class.getEnumConstants .

sealed-enum was born to solve both of these problems, and in-effect make defining actual enum classes obsolete in favor of sealed classes of objects, which I call “sealed enums”. Taking inspiration from kotlinx.serialization’s KSerializer objects, annotating the companion object of a properly structured sealed class T with @GenSealedEnum will result in a generated implementation of SealedEnum<T> . That implementation is an object, which provides the values list and ordinalOf method, implements Comparator based on those ordinals, and also includes nameOf and valueOf methods:

Together, these methods match all of the features of normal enum classes.

For an example of how this works, let’s first add sealed-enum as a Gradle dependency to our project:

The sealed-enum library consists of two parts: a small runtime library, which defines the annotations and the interfaces, and an annotation processor for kapt .

Now that we have the library installed, let’s annotate a newly created companion object for the ScrollingBehavior sealed class above:

The annotation processor then generates a ScrollingBehaviorSealedEnum object, which implements SealedEnum<ScrollingBehavior> , as well as extension properties and functions for ease-of-use:

Now, by retrieving ScrollingBehavior.values , or by passing the SealedEnum object around directly, we have all of the features we want from an enum and get to keep all of the extra power that sealed classes offer.

The runtime library and annotation processor also have built-in tools to specify behavior and provide interoperability.

@GenSealedEnum can be configured to specify a traversal order, to switch between in-order, pre-order, post-order and level-order traversal for sealed class hierarchies.

For interoperability, there is built-in functionality for both directions: First, there are methods provided in the runtime library to create a SealedEnum object given a normal enum class. Second, @GenSealedEnum can be configured to generate an isomorphic enum class that implements all of the interfaces that the sealed enum does via interface delegation. If you need to specify an enum value in an annotation, or provide a true enum class to some other interface, you can easily map between the two representations as needed.

Enum classes are great, but sealed classes with objects can be better. By generating the remaining boilerplate to fill in the gaps, you too can upgrade your enums to sealed enums, and never write a normal enum class ever again.

Alex works at Livefront, where we write code to test code-writing code.