Sealed Classes

Sealed classes in Kotlin have nothing to do with wax, aquatic mammals, or musicians.

Instead, sealed classes are a way of limiting a class hierarchy. A class can only directly extend a sealed class if:

As a result, once that one file is read in by a Kotlin compiler, it immediately knows the complete possible set of all direct subclasses of the sealed class. This is a bit reminiscent of an enum class, which also has rules for what can subclass it (specifically, only its constant elements).

Um, Why?

A key limitation of an enum class is that each of its constant members is not only a subclass of the enum class, but is a singleton instance of that subclass. Going back to the HttpResponse scenarios from above, there is exactly one OK instance for the entire program. So an enum class limits not only where subclasses can reside but where instances can be declared.

In contrast, a sealed class controls the class hierarchy, but it does not limit instance creation. There can be N instances of a subclass of a sealed class, each with its own data.

However, since the subclasses of the sealed class are readily identifiable — they are all in the same Kotlin source file — we get some of the same benefits that we get with enum classes, notably the exhaustive when support.

Basic Declaration and Usage

To create a sealed class, start by adding the sealed keyword:

sealed class BrowserLocation(val url: String)

Sealed classes are abstract, so you can add anything that you want that works with an abstract class: properties, concrete functions, abstract functions, etc.

Then, add nested classes and objects that extend from the sealed class:

sealed class BrowserLocation(open val url: String) {
  object HomePage : BrowserLocation("https://commonsware.com")

  data class Bookmark(override val url: String, val name: String) : BrowserLocation(url)

  data class HistoryEntry(override val url: String, val title: String, val lastVisited: String) : BrowserLocation(url)
}

fun main() {
  println(BrowserLocation.HomePage.url)

  val bookmark = BrowserLocation.Bookmark("https://kotlinlang.org", "Kotlin!")

  println(bookmark)
}

You then refer to them the same as you would any other nested classes or objects (e.g., Browser.HomePage). So, running this script results in:

https://commonsware.com
Bookmark(url=https://kotlinlang.org, name=Kotlin!)

However, there is nothing particularly magical about BrowserLocation being sealed so far. You could replace sealed with abstract and get the same results.

Exhaustive When

A sealed class, like an enum, supports an exhaustive when. All possible classes and objects that directly extend the sealed class are known when the sealed class is compiled. So long as your when covers all of those possibilities, you do not need an else clause:

sealed class BrowserLocation(open val url: String) {
  object HomePage : BrowserLocation("https://commonsware.com")

  data class Bookmark(override val url: String, val name: String) : BrowserLocation(url)

  data class HistoryEntry(override val url: String, val title: String, val lastVisited: String) : BrowserLocation(url)
}

fun main() {
  val location: BrowserLocation = BrowserLocation.Bookmark("https://kotlinlang.org", "Kotlin!")

  val title = when (location) {
    BrowserLocation.HomePage -> "Home"
    is BrowserLocation.Bookmark -> location.name
    is BrowserLocation.HistoryEntry -> location.title
  }

  println(title)
}

This prints:

Kotlin!

Smart Casts

That too may seem somewhat unremarkable. However, something very interesting is going on with the latter two branches of the when.

location is a BrowserLocation variable, because we explicitly set that to be the data type:

val location: BrowserLocation = BrowserLocation.Bookmark("https://kotlinlang.org", "Kotlin!")

The first branch of the when compares location with BrowserLocation.HomePage — if location is that singleton object, then we return "Home" as the title. That is fairly normal.

However, the second branch checks to see if the type of location is BrowserLocation.Bookmark. If it is, then we return the name property. But location is a BrowserLocation variable, and BrowserLocation does not have a name property. If we try referencing it directly, we get a compile error. So, this:

println(location.name)

…results in:

error: unresolved reference: name
println(location.name)

So, why does it work in the when?

That is because Kotlin realizes that if we are executing location.name in that branch, location must be a BrowserLocation.Bookmark — otherwise, we would have failed the is check and would not be taking that branch. Since Kotlin knows that location must be a BrowserLocation.Bookmark, Kotlin knows that it is safe to reference the name property.

The same approach is used for the third branch. We can safely reference location.title there, because if we are executing that branch, we know that location is a BrowserLocation.HistoryEntry, which has a title property.

This is another variation on Kotlin’s “smart casts” that we saw earlier in the book. The Kotlin compiler can use knowledge of how code gets executed to allow you to avoid manual casts, because what you want to do happens to be valid for objects in that state.

Smart casts are very useful, particularly when dealing with null values, as we will explore in an upcoming chapter.

Scenario: Valid and Invalid Data

A common pattern involving sealed classes comes when interacting with some server or other external source of data. Usually, there are two main outcomes from such an interaction:

One way to model those responses is to have a sealed class hierarchy that represents both types of outcomes. If we are going to treat all errors the same, we could use the approach shown with BrowserLocation, where we have a mix of classes and objects in the sealed class:

sealed class ThingyResponse {
  data class Thingy(val something: String, val somethingElse: Int) : ThingyResponse()

  data class OtherThingyThatWeMightGet(
    val like: Float,
    val whatever: String,
    val dude: Boolean
  ) : ThingyResponse()

  object Invalid : ThingyResponse()
}

class WebServiceApi {
  fun requestThingy(): ThingyResponse = ThingyResponse.Invalid
}

Here, our WebServiceApi can return a ThingyResponse from requestThingy(), and that can encompass both positive and negative outcomes. Right now, that function is stubbed out by always returning ThingyResponse.Invalid, but we could have a full Web service request here that parses results, generates ThingyResponse.Thingy or ThingyResponse.OtherThingyThatWeMightGet objects, and so on.

Callers of requestThingy() and anything else that gets a ThingyResponse can use an exhaustive when to handle all of the possibilities.

Another variant is to have Invalid be a class instead of a singleton object. That class can then hold details of what went wrong:

Scenario: Loading/Content/Error

A common pattern for a UI is:

Similar to the valid-and-invalid data scenario above, a sealed class lets us model these three states with a single core type:

sealed class SomethingViewState {
  object Loading : SomethingViewState()

  data class Content(val goodStuff: List<Stuff>) : SomethingViewState()

  object Error : SomethingViewState()
}

For example, in Android app development, you might emit instances of this SomethingViewState from a ViewModel, perhaps using the Jetpack LiveData class or StateFlow from Kotlin’s coroutines. Your UI layer (activity, fragment, composable) could observe those states and use an exhaustive when to handle all possible state sub-types (Loading, Content, Error).

Limitation: Location

Direct subclasses of the sealed class must be in the same file that contains the sealed class itself. They do not necessarily need to be nested inside the sealed class, though.

So, normally, this is perfectly valid… in most Kotlin environments:

sealed class BrowserLocation(open val url: String)

object HomePage : BrowserLocation("https://commonsware.com")

data class Bookmark(override val url: String, val name: String) : BrowserLocation(url)

data class HistoryEntry(override val url: String, val title: String, val lastVisited: String) : BrowserLocation(url)

fun main() {
  val location: BrowserLocation = Bookmark("https://kotlinlang.org", "Kotlin!")

  val title = when (location) {
    HomePage -> "Home"
    is Bookmark -> location.name
    is HistoryEntry -> location.title
  }

  println(title)
}

Early versions of Kotlin required sealed class sub-types to be nested in the sealed class. Also, some REPL environment require nesting.

However, most Kotlin projects are not written in a REPL and are using modern versions of Kotlin, so having a subclass of the sealed class be a peer of the sealed class is fine. The “must-be-nested” rule applies for .kts files, not .kt files.

If you declare a direct subclass of the sealed class to be open, though, you can extend that class normally, even from other source files. So, in a regular Kotlin project (not a REPL), if you have one source file with:

import java.time.Instant

sealed class BrowserLocation(open val url: String)

object HomePage : BrowserLocation("https://commonsware.com")

open class Bookmark(override val url: String, open val name: String) : BrowserLocation(url)

data class HistoryEntry(override val url: String, val title: String, val lastVisited: Instant) : BrowserLocation(url)

…and in another source file, you have:

class BrokenSeal(override val url: String, override val name: String) : Bookmark(url, name)

…then you are OK. Here, Bookmark is marked as an open class, with both of its constructor properties being open as well (url is declared as open up in BrowserLocation). As a result, we can extend Bookmark from a separate Kotlin source file, as BrokenSeal does.


Prev Table of Contents Next

This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.