MichD

 

A Love Letter to Kotlin's sealed interface

in Blog

I’ve been writing Android apps in Kotlin for some 7 years now, and in the last couple years I’ve really come to appreciate how its features enable building robust systems. One feature in specific is usually central to what I come up with: the sealed interface.

Below, I introduce the sealed interface and explain some of the qualities and restrictions that make it so powerful. I then provide some real world examples and talk a bit about some software engineering principles I’ve picked up on over the years.

This post assumes you have experience writing object-oriented software, and understand the concept of interfaces, classes, instances, and the rough idea of compilers. My Kotlin background is in Android app development, so that is the context of my examples, but the ideas are certainly applicable elsewhere.

What is a sealed interface?

You can go read it in the language authors’ own words if you like: Sealed classes and interfaces on kotlinlang.org … but I’ll try my own hand at explaining them as well.

A sealed interface is an interface for which all the direct implementations are known at compile time.

Known at compile time

This is the primary thing, because it carries very useful implications. Knowing of all implementations at compile time allows the language to have constructs that require all of them to be covered for the program to be valid. This construct is “when”—with roots in the switch/case you may know in other languages.

In a when (expression) control flow or expression, you write branches for all possibilities for the given expression. If you omit any possibility and have no else -> fallback, the compiler will complain and tell you about the options that aren’t covered.

If we supply an expression that’s an instance of a sealed interface type, we can check against things about that instance to cover all possibilities. Typically we’ll want to go down different code paths for different implementations of the sealed interface type.

This could look as follows:

sealed interface UserContext {

    data object Unauthenticated : UserContext
    data class Authenticated(val user: UserData) : UserContext
}

The above example represents the state of a user in an app. Either they’re logged out (Unauthenticated), or they are Authenticated, and we have some further data of them available.

Say we want to navigate to a different area of the app based on this value:

fun navigateBasedOnUserContext(context: UserContext) {
    when (context) {
        UserContext.Unauthenticated -> navigation.toLoginScreen()

        is UserContext.Authenticated -> navigation.toMemberArea(context.user.id)
    }
}

Now let’s say we want to add another possibility to UserContext: PayingUser:

sealed interface UserContext {

    data object Unauthenticated : UserContext
    data class Authenticated(val user: UserData) : UserContext

    // New:
    data class PayingUser(
        val user: UserData,
        val subscription: SubscriptionData
    ) : UserContext
}

If we try to compile this without further ado, the earlier navigateBasedOnUserContext function will fail to compile, since neither of its two when branches matches context being an implementation of UserContext.PayingUser.

Tip: A good IDE like IntelliJ IDEA or the derived Android Studio will have a contextual option “add remaining branches” that provides stubs for each branch you’ve not already specified, saving you the time looking up all the implementations.

Direct implementations

You can partially open up a sealed interface for expansion, by defining an interface that extends the sealed interface, but isn’t itself marked sealed. This can be useful if you are writing a library type where a few built-in implementations exist, but you want to open up a separate avenue for application developers to expand it, while keeping the benefits of a sealed interface for your baked-in implementations.

Let’s discuss a contrived example:

sealed interface Shape {

    data class Square(val topLeft: Point, val size: Float) : Shape

    data class Circle(val centre: Point, val radius: Float) : Shape

    interface Custom : Shape {
        fun calculateSurfaceArea(): Float
        fun draw(canvas: Canvas)
    }
}

In this example, where we might have some shape rendering / related maths library, the Shape sealed interface comes with some built-in implementations Square and Circle that cannot be overridden by app developers.

In this library, Square and Circle don’t provide their own implementations of calculateSurfaceArea or draw, because perhaps there are more efficient ways to do that work in this library. Let’s pretend it comes with an extensive set of such shapes that are all very well optimised. Then an app developer wants to do something super specific not covered by the existing shapes. To accomplish that, our library provides an option by means of the Shape.Custom interface which requires a few methods for the library to do its thing.

The library’s internals can check which implementation of Shape we’re dealing with, and as appropriate, use optimised internal methods, or, in the case of a Shape.Custom implementation, use the user-supplied methods.

At the time the library is compiled, the compiler is not aware of what implementations of Shape.Custom there might be, but it does know that all implementations of Shape will be either Square, Circle, one of the plethora of other ones I’ve implied the existence of, or lastly, Custom. It knows all it needs to about Custom for the decision trees to work.

Look, I did warn it was pretty contrived. This is only to show that there is scope to still leave a sealed interface open-ended, so long as all direct descendants are known at compile time.

Mixing sealed interfaces

Since any class can implement multiple interfaces, the same is true for implementations of sealed interfaces. A direct “implementation” of a sealed interface can itself be another sealed interface, applied to only a subset of the final implementations.

This in turn can be used to write methods that apply to anywhere between 1 and all of the descendants of the topmost sealed interface you’re working with.

These additional sealed interfaces can be devoid of properties or methods (as can the top level ones, for that matter), but serve purely as a semantic marker, enforced by the compiler.

These semantic markers can provide helpful information to different areas of your codebase, each allowing them to implement decision trees based on qualities of the data that matter at that point, all without converting your instance to some other type as it moves through the layers in your codebase.

Real world example: a settings screen

I think an example here would be effective. I’m currently reworking a settings page of an app. This page is built up out of sections with a heading and a number of items consisting of a static label + either some value, a switch, or an indicator that clicking it will navigate somewhere.

I’m writing the UI for this screen in Compose - you don’t have to dive too deep into it but to put it simply: you write Kotlin code that emits UI pretty much, instead of XML that you then interact with. It allows for a lot of code re-use and (surprise!) composition of components into bigger ones.

The settings page may be put together in a number of different variants based on some factors that aren’t relevant here. Suffice to say, some sections or items may be left out, or some may be re-ordered.

In building the UI elements, I identified a small number of element types. The actual UI code doesn’t need to know much about the content of these items, but it does need to know how to render them.

I came up with this sealed interface to represent items:

sealed interface SettingsItem {

    // Anything that has static text mapping to a string resource
    sealed interface StaticLabel : SettingsItem

    // The heading above a number of items
    sealed interface Heading : StaticLabel

    // A row with a static label + some dynamic text
    sealed interface InfoItem : StaticLabel {
        val value: String?
    }

    // A row with a static label + a toggle switch
    sealed interface SwitchItem : StaticLabel {
        val isOn: Boolean?
    }

    // A row that will navigate when clicked
    sealed interface NavItem : StaticLabel

    // On to the actual implementations, representing what will be shown:

    data object MyAccountHeading : Heading
    data class EmailAddress(override val value: String?): InfoItem

    data object InterfaceHeading : Heading
    data class GoToDetailOnTuneInSwitch(override val isOn: Boolean?) : SwitchItem
    data class ShowNowPlayingOnChannels(override val isOn: Boolean?): SwitchItem

    data object CompanyHeading : Heading
    data object AboutLink : NavItem
    data object TermsOfServiceLink : NavItem
    data object PrivacyPolicyLink : NavItem

    // ...
}

On the ViewModel side of things, we’ll put together a list of these items. Putting this list together requires no knowledge of the subtype of item, so typing it a List<SettingsItem> is enough.

On the UI end of thing, we barely care about the specific named final implementations. We’ll have a specific component for a Heading, and another one for an InfoItem, yet another for a SwitchItem.

We do care about the named final implementations when we populate text. Value text like in InfoItem is contained within the instance (like SettingsItem.EmailAddress) but we also have a bunch of things that implement SettingsItem.StaticLabel: Heading, InfoItem, SwitchItem, NavItem. Text like this that doesn’t come in dynamically, is hardcoded in the application. It’s good practice to make these localiseable, so on Android we use “string resources” to accomplish that.

Every single item like this implements SettingsItem.StaticText, so we get the compiler to help us:

private fun getStaticText(item: SettingsItem.StaticLabel) = stringResource(
    when (item) {
        SettingsItem.MyAccountHeading -> R.string.my_account
        SettingsItem.EmailAddress -> R.string.email_address
        SettingsItem.InterfaceHeading -> R.string.interface
        SettingsItem.GoToDetailOnTuneIn -> R.string.go_to_detail_on_tune_in
        SettingsItem.ShowNowPlayingOnChannels -> R.string.show_now_playing_info
        SettingsItem.CompanyHeading -> R.string.company
        SettingsItem.AboutLink -> R.string.about
        SettingsItem.TermsOfServiceLink -> R.string.tos
        SettingsItem.PrivacyPolicyLink -> R.string.privacy_policy
    }
)

Again, if there’s any item we missed, the compiler will fail to compile and point out the missing branches.

Now, back in the ViewModel calss, we want to handle when user clicks a NavItem, by initiating the relevant navigation. Rather than expose a differently named method for every such link and have the UI code be aware of all of them, we instead implement a single method:

fun onNavItemClick(item: SettingsItem.NavItem) {
    when (item) {
        SettingsItem.AboutLink -> navigation.toAboutPage()
        SettingsItem.TermsOfServiceLink -> navigation.toTos()
        SettingsItem.PrivacyPolicyLink -> navigation.toPrivacyPolicy()
    }
}

The when construct only needs to cover all the SettingsItem implementation that implement NavItem, immediately narrowing down the list.

Our API surface is smaller, we spend less time typing out the same things, and thus the possibility of bugs is lowered. We let the compiler do the work of making sure all the bases are covered.

Musings on Architecture

The applications I spend the majority of my days concerned with are built up around the “clean architecture” principle. I very much enjoy the benefits of this kind of architecture.

Over time, our small team fell into a pattern that isn’t outlined in the article: implementing the same things but slightly different, across many of the layers. While it made the purpose of every step extremely clear (“screaming architecture” if you will), it did end up eschewing the more basic “don’t repeat yourself” rule.

Essentially, we often ended up working on some new use case, and then implement all the requirements for it with new UseCase classes, Managers, Repository interfaces and implementations, local and remote “data sources”, repeating the naming that refers to the eventual use cases across the layers.

When taking a step back, many of these represented very similar tasks either from a user perspective or as implementation details.

Simplifying a repetitive internal process

A very glaring example I recently addressed is how we went about persistently storing simple true/false values. Some tasks involving this are:

  • Reading the value
  • Updating the value
  • Watching the value for changes

The damning thing was that for nearly all flags or settings of this nature, we had methods of this variety implemented across several layers of the architecture. For 2 settings that were no more than a toggle switch, we had separate “managers” with a “listener” subscribtion system. All of these had methods in a local data source interface and thus its implementation. Some had mentions in a repository layer that put it straight through the data source, again, for each one of such boolean settings.

This setup did allow specifying the precise injected dependencies for accessing just the relevant flags, but that is a lot of cruft to accomplish a very common goal.

As you might expect, sealed interfaces were part of the solution to clean this up.

Just a couple of them:

sealed interface PersistentFlag
sealed interface MainSettingFlag : PersistentFlag {
    data object AutoLockScreen : MainSettingFlag
    data object HideNowPlayingInfo : MainSettingFlag
}

I defined a main PersistentFlag sealed interface, and considered booleans represented semantically as a “flag” are set (true) or not set / cleared (false). Then per relevant domain such as the “main settings” here, I extended to another sealed interface. This let me create components that provide only access to a subset of the flags by using type arguments, rather than repeated implementations.

Still in the domain layer, I defined an interface PersistentFlagsDataSource that can take any PersistentFlag, and provides methods for the relevant operations from earlier. This in turn is implemented once in the framework layer, where a when control structure maps the specific final implementations to relevant key/value storage keys. The mapping happens once. The setting/flag names are defined once, and never repeated in a bunch of method names for each operation on that specific one.

Everywhere in the app that requires access to reading, writing, or watching a specific subset of these flags can do so by using a type-limited wrapper around the single data source. Access remains limited by need-to-know, and “clean” principles are respected entirely.

We can more easily add new flags/settings now, and there is a lot less code that could have a bug sneaking in.

Simplifying similar user scenarios

An application can also have cases where the user performs an action that is similar, but they operate on content that has different internal semantics / details. This might be pretty abstract so I’ll immediately move on to my example.

In our music app, we have “radio channels” per subgenre that the user can list to. These channels are basically set and forget, you click it and you just get “endless” music in the genre, as you would expect from radio.

We further have “playlists” in a separate section. They similarly are tap to play, but encompass a much smaller, visibly finite amount of music. A playlist shows its number of tracks and total duration of the music. You can reach the end of it and the app will tell you so.

Lastly there’s a library of DJ-hosted mix shows. Many of these have a list of previous episodes that can be played on demand rather than in the original context of airing live on a radio channel. These just play one mix audio file (typically 1-2 hours long) and then stop.

With the description of these out of the way, it’s clear there are some difference between them, but there are also commonalities:

  • The user can tell the app to start playback on any one of them
  • They all have some displayed name
  • They all have some associated image

Now, before I spent some time reworking it, we had several methods like:

  • playChannel(id: Long) or playChannel(channel: Channel)
  • playPlaylist(id: Long) or playPlaylist(playlist: Playlist)
  • playShowEpisode(showSlug: String, episodeSlug: String).

These methods then took various paths to eventually get to a common point of audio tracks making it into the player machinery, and audio coming out of some speakers.

In my opinion this carried some issues:

  • Duplicated code for in areas where the differences were not relevant
  • Identifying content by an id without typing enforced
  • in many cases: Starting from a known fully formed object like a Channel, then passing just the id into the method, and later down the line having to resolve that back to a Channel
  • Player UI and various other places duplicating work for channels, playlists, show episodes to determine what info / images to show.

To address this and more, I designed another sealed-interface-based type called PlaybackContent; a simplified version:

sealed interface PlaybackContent {

    sealed interface ShallowContent : PlaybackContent
    sealed interface ResolvedContent : PlaybackContent

    sealed interface RadioContent : PlaybackContent {

        data class Shallow(
            val channelId: Long,
        ) : RadioContent, ShallowContent

        data class Resolved(
            val channel: Channel,
        ) : RadioContent, ResolvedContent
    }

    sealed interface PlaylistContent : PlaybackContent {

        data class Shallow(
            val playlistId: Long
        ): PlaylistContent, ShallowContent

        data class Resolved(
            val playlist: Playlist,
        ) : PlaylistContent, ResolvedContent
    }

    sealed interface ShowEpisodeContent : PlaybackContent {

        data class Shallow(
            val showSlug: String,
            val episodeSlug: String,
        ) : ShowEpisodeContent, ShallowContent

        data class Resolved(
            val showEpisode: ShowEpisode
        ): ShowEpisodeContent, ResolvedContent
    }
}

This type evolved from a more singular purpose type called “TuneInRequest” that had a similar structure, but it later grew into having much wider use and hence got renamed.

We might only have primitive data (id or slug) about the content that we need to resolve into a full object with everything we need in order to start playback or do something else with. All of those versions are covered by the PlaybackContent.Shallow type. Where it becomes relevant to get the full data, we can pass any PlaybackContent.Shallow instance to a component dedicated to resolving it, which internally works out whether we’re looking for a channel, playlist, or show episode and knows where to get each of those. It then returns a PlaybackContent.Resolved that can be passed on to the next step in the process.

We can then design other components to work with just PlaybackContent.Resolved, narrowing down the number of options it has to cover.

Once we’ve started playback, we can publish the content to interested partiess as an instance PlaybackContent.Resolved. The player UI and other places might not care whether it’s a channel, playlist or show episode, so long as it can get to a title of the content, and get an image. These are things properties or methods that might be expected on PlaybackContent.Resolved, or they can be provided with extension properties, and so forth. Ror the points where it matter what the specific implementation is, our friend when can take care of covering all the bases like before.

This particularly type has a bunch more things going on in it that simplify things around the app, so this is only scratching the surface. The end result is less code that deals with same but slightly different variants, less things that need unit test coverage, and an overall much more maintainable system.

Closing thoughts

Some good principles have become apparent to me over time:

  • If you can let the compiler enforce something for you, do it. The less you rely on your own or other folks’ ability to notice easy-to-make mistakes, the more confident you can be in your codebase. Deterministic tools are excellent for this.
  • A good way to achieve this is by designing good types.
  • If you’re doing similar things, or doing things a similar way, try to find ways to translate those commonalities to shared code paths. Diverge them again only where the differences are relevant.

The net result of these is that you end up with less code, and less code means less surface area for bugs. And if you can enforce a rule by the compiler, that’s at least one less unit test to write to cover it… which is less code once more.