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:
- In a regular project, it is in the same Kotlin file as is the sealed class itself
- In a REPL, it is a nested class inside of the sealed class
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:
- We get valid data
- We receive some sort of error, either from the source itself (e.g., a Web service responding with a JSON object indicating that our request failed) or from infrastructure around that source (e.g., an
IOException
from being unable to reach the Internet)
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:
- Error code from the server
- An exception that was raised by the network I/O
- Etc.
Scenario: Loading/Content/Error
A common pattern for a UI is:
- We need to show some loading state, such as a progress spinner, while disk or network I/O is ongoing
- When we get the content to display, display that
- If something went wrong, show some sort of error state
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.