zsmb.coGap-buffered!



Sealed goodies coming in Kotlin 1.5

2021.01.19. 14h • Márton Braun

Preview!

Heads up: most things covered in this post are in a preview state of some sort.

If you want the full technical details on this topic, you can jump to KEEP-226 and its design proposal instead of reading this article.

JDK15 introduced a version of sealed classes and interfaces (see JEP 360) in a preview state. These let you restrict inheritance hierarchies, and perform checks on these types with pattern matching. (See this article for Java example code.) These features are expected to be stable in JDK17 at the earliest.

In the meantime, Kotlin has had sealed classes since its release, but not sealed interfaces. Now that new JDK versions are adding support for these constructs, Kotlin needs to have an idea of them as well, so that it’ll be able to interoperate with new Java code.

In this article, we’ll recap what sealed classes do, how they’ve evolved so far, and what new goodies you can expect to get when Kotlin 1.5 lands.

Update: As of May 5, 2021, the features here are now available in stable Kotlin versions 1.5 and later, see here for the 1.5 changes.

Sealed classes 101

Sealed classes are a really popular Kotlin feature: they allow you to restrict the type hierarchy, and prevent unknown subclasses of a base class. They’re kind of like enums with super powers: while enums define a fixed set of instances, sealed classes define a fixed set of types. Importantly, each “branch” of a sealed class is a full-fledged Kotlin class, and can have its own properties and methods.

Originally, all subclasses had to be nested inside the sealed class:

sealed class MenuItem {
    object Cake : MenuItem()
    class Hamburger(val toppings: List<Topping>) : MenuItem()
    class Soda(val size: Size) : MenuItem()
}

This made sure that after you compiled the sealed class, nobody else could come along and create their own class that extends it. Anyone adding a new item inside the class would also have to recompile the class itself, and any code that performs type checks against this class.

You can still see this pattern of nesting in many usages of sealed classes today, as it’s still useful for scoping names (e.g. MenuItem.Soda is a nice descriptive name).

One of the great things about sealed classes is that when can guarantee that you’ve exhaustively checked all possible cases (given that you’re using it as an expression, i.e. returning something from it):

val rating = when (val item: MenuItem = getMenuItem()) {
    is Cake -> 10
    is Hamburger -> if (Cheese in item.toppings) 9 else 7
    is Soda -> 2
}

You also get convenient smart casts within branches to the specific subclass, which is how item.toppings can be referenced in the case of a burger above.

Road to sealed classes freedom

In Kotlin 1.1, the containment requirements were relaxed, requiring only that subclasses are in the same file as the sealed class, allowing you to get rid of the nesting:

sealed class MenuItem

object Cake : MenuItem()
class Hamburger(val toppings: List<Topping>) : MenuItem()
class Soda(val size: Size) : MenuItem()

The preview features of Kotlin 1.5 relax this even further, allowing you to place your subclasses into different files as well. The remaining requirement is that the subclasses have to be in the same compilation module and same package as the sealed class.

// MenuItem.kt
sealed class MenuItem

// Food.kt
object Cake : MenuItem()
class Hamburger(val toppings: List<Topping>) : MenuItem()

// Drinks.kt
object Water : MenuItem()
class Soda(val size: Size) : MenuItem()

This way, you’ll still know all possible types that might extend a sealed class at compile time, as the entire module is compiled together. However, you can freely organize your subclasses logically, and you can also split out long subclasses into separate files for readability.

As always, if you get the rules wrong, you’ll get a really nice and descriptive error message from the IDE:

e: Inheritance of sealed classes or interfaces from different module is prohibited

e: Inheritor of sealed class or interface must be in package where base class is declared

You can also now inherit from classes that are on the same level of nesting in the code, but on different levels of a sealed class hierarchy (KT-13495):

sealed class MenuItem(val price: Double) {
    sealed class Hamburger : MenuItem(4.65)
    class CheeseBurger : Hamburger()
}

Sealed interfaces

Sealed interfaces are… Just what they sound like. An interface with restricted implementations. The rules for this are the same as the new rules for sealed classes: same module, same package.

Why is this useful? Because it allows you to expose interfaces from a module (or library, if you will) which its clients won’t be able to implement. These kinds of non-user-implementable interfaces are great: you can still use interfaces to hide concrete implementation classes in your module, but don’t run the risk of someone trying to reimplement your interface.

I find that most of the time, this is what I want an interface to behave like in the first place. I don’t want multiple implementations of it, especially not by clients of my code, I just want to hide the concrete classes that implement it.

Take this example, which defines a Restaurant interface, and some public API that lets you get an instance:

public sealed interface Restaurant {
    val menu: List<MenuItem>
    fun purchase(item: MenuItem, quantity: Int)
}

public fun openRestaurant(): Restaurant { ... }

By making this interface sealed, we prevent clients from creating their own implementations, while also being able to create multiple internal implementations ourselves, which the clients will never know about:

internal class BurgerPlace : Restaurant {
    override val menu: List<MenuItem> = ...
    override fun purchase(item: MenuItem, quantity: Int) { ... }
}

As a bonus, sealed interfaces should also be able to have internal members, as they’re only implemented within the same module anyway. (For open, public interfaces, this wouldn’t make sense, as nobody outside the module could implement an internal member.)

This isn’t available even as a preview yet, however, you could theoretically use this to add members to an interface that should not be public API, but you want to access them within your own module:

public sealed interface Restaurant {
    public val menu: List<MenuItem>
    public fun purchase(item: MenuItem, quantity: Int)
    internal val totalIncome: Double
}

For more details on this, see KT-22286.

Java interop considerations

Kotlin always has some rough edges when it comes to interoperating with Java code, as Java is inherently less safe and less strict with its typing. For example, calling a Kotlin method with a null value it doesn’t expect is trivial to do from Java.

It’s interesting to see that perfect Java interop is not an objective for Kotlin language design. You can see this both in the KEEP discussions and on the issue mentioned in the previous section, which notes:

The fact that this restriction is would be non-enforceable for Java code is not a show-stopper.

So how about sealed constructs and interop then?

Sealed classes are still safe from Java code, as they’ve always been, as they have private constructors in the bytecode, preventing anyone from extending them. (Their inheriting subclasses in Kotlin use a secondary, synthetic constructor which is not available from Java).

Sealed interfaces, on the other hand, get no protection from Java code. This means that Java clients can still freely extend these interfaces if they really want to do so, and the Java compiler will allow it. (This can change when running on JDK versions that support sealed types with a stable API, likely 17 or later.)

In the meantime, if you’re using an IDE that understands Kotlin’s sealed types, such as IntelliJ IDEA, it will do its best to stop you from extending them from Java code by emitting inspection errors, such as:

e: Java class cannot be a part of Kotlin sealed hierarchy

Finally, it’s worth noting that mixing sealed hierarchies between the two languages will not be supported. Each sealed hierarchy will have to be made up of either Kotlin or Java classes.

Conclusion

Kotlin 1.5 can’t land quickly enough (edit: it’s now landed, go and try it!), it’s always nice to see the Kotlin type system become even more powerful, and allow even finer control over what clients are allowed to access and extend.

For the full details and all the relevant issues around this topic, see KEEP-226 and its design proposal.



You might also like...

Pi Practice App in Compose

In another detailed Jetpack Compose walkthrough, we'll look at implementing a simple app for practicing the digits of pi!

Retrofit meets coroutines

Retrofit's coroutine support has been a long time coming, and it's finally coming to completion. Take a look at how you can use it to neatly integrate networking into an application built with coroutines.

Maintaining Compatibility in Kotlin Libraries

Not breaking client code is an essential duty of a library developer. Let's take a look at a couple rarely discussed issues you might face in this area.

Mastering API Visibility in Kotlin

When designing a library, minimizing your API surface - the types, methods, properties, and functions you expose to the outside world - is a great idea. This doesn't apply to just libraries: it's a consideration you should make for every module in a multi-module project.