In Chapter 5, we saw how limiting our options can be a good thing. In that chapter, we used enum classes to limit our values, which allows Kotlin to ensure that we account for all possibilities in a when expression.
We can get a similar benefit for our types by using sealed interfaces and classes. In this chapter, we’ll visit Cecil’s Ice Shop to learn all about sealed types.
Let’s get started!
Cecil’s Ice Shop
In the frigid lands of the Antarctic, there’s a store called Cecil’s Ice Shop, a thriving business where the locals can buy containers of ice cubes in three different sizes.
It’s a simple operation—when customers want to place an order or ask for a refund, they show up to the front desk and fill out a request form. From there, the front desk sends the request off to the ice cube factory, which handles the fulfillment.
Cecil, the store’s owner, is also modeling out his operations in Kotlin code. To start with, he created an enum class to represent those three sizes of ice cube packages.
enum class Size { CUP, BUCKET, BAG }Next, for the order and refund requests, he created an interface named
Request.The front desk deals with lots of requests each day, so in order to keep track of them all, each one has a unique ID number. So, he included a property named
idin theRequestinterface.interface Request { val id: Int }Next, he added two classes that implement that interface—one for placing an order, and one for requesting a refund.
class OrderRequest(override val id: Int, val size: Size) : Request class RefundRequest(override val id: Int, val size: Size, val reason: String) : RequestThen, in his Kotlin code, Cecil created a
FrontDeskobject to receive aRequest. The front desk records that it received the request by printing out its ID number.After that, he uses a
whenconditional to do a smart cast, and sends the request along to the correct function at the ice cube factory, where the request will be fulfilled.object FrontDesk { fun receive(request: Request) { println("Handling request #${request.id}") when (request) { is OrderRequest -> IceCubeFactory.fulfillOrder(request) is RefundRequest -> IceCubeFactory.fulfillRefund(request) } } }Speaking of the ice cube factory, Cecil isn’t too concerned about exactly how it handles orders and refunds. So, in his Kotlin code, he created an
IceCubeFactoryobject that just prints out a message as each request is being fulfilled.object IceCubeFactory { fun fulfillOrder(order: OrderRequest) = println("Fulfilling order #${order.id}") fun fulfillRefund(refund: RefundRequest) = println("Fulfilling refund #${refund.id}") }With this simple Kotlin code, a customer can now order a cup of ice! The front desk receives the order and forwards it to the ice cube factory for fulfillment.
val order = OrderRequest(123, Size.CUP) FrontDesk.receive(order)Cecil’s code also allows a customer to request a refund, simply by giving the front desk a refund request.
val refund = RefundRequest(456, Size.CUP, "Accidentally ordered too much") FrontDesk.receive(refund)With this code, Cecil’s Ice Shop continued fulfilling orders and refunds, making many satisfied customers… until one day when Cecil needed to add one more request type!
Adding Another Type
One day, a customer named Wallace needed help opening his bag of ice. With that massive body and those slippery flippers, it’s hard to blame a walrus for not being able to open that bag!
Cecil decided that it was time to start offering customer support. In order to help customers like Wallace, Cecil came up with plans to add a new kind of request, called a support request.
A customer who needs help could just fill out a support request with text about the problem, and hand it to the front desk. The front desk would forward it on to a new help desk.
Naturally, it was easy for Cecil to add a new request type in his Kotlin code. He just created a new class called
SupportRequest, which implemented theRequestinterface. In addition to theidproperty, this new class added only atextproperty, where customers can write a description of the help that they need.class SupportRequest(override val id: Int, val text: String) : RequestAs with the ice cube factory, instead of including details about how the help desk actually helps, Cecil’s code simply included a
println()statement to log that the request was received.object HelpDesk { fun handle(request: SupportRequest) = println("Help desk is handling ${request.id}") }Great! Cecil hit the “Run” button in his IDE, and his shop was up and running again. Wallace submitted his help request, and was told that someone from the help desk would follow up with him.
val request = SupportRequest(789, "I can't open the bag of ice!") FrontDesk.receive(request)A few days later, though, Wallace returned, as grumpy as ever. “Why hasn’t anyone contacted me about my support request?” he asked.
Embarrassed, Cecil combed through his program’s output for Wallace’s support request ID number to see what happened. To his surprise, he only found one line about it.
Handling request #789“The front desk recorded that it received the request, but that was all! The help desk apparently never received it!” noted Cecil. What had happened? He pulled up the code for the front desk again.
object FrontDesk { fun makeRequest(request: Request) { println("Handling request #${request.id}") when (request) { is OrderRequest -> IceCubeFactory.fulfillOrder(request) is RefundRequest -> IceCubeFactory.fulfillRefund(request) } } }“Of course!” he cried, “I forgot to add a branch to the
whenconditional for the newSupportRequesttype!” He would have slapped his forehead, but his flipper couldn’t reach his head.Cecil noticed how easy it is to forget to add a branch to his
whenconditional when he adds a new subtype. He mused, “I’ve only got onewhenconditional in my code right now. How much easier would it be to forget a branch if I had even more of them scattered throughout my code? Too bad I didn’t catch this problem until a customer complained about it!”Instead of waiting for a customer to report a problem with his code, it’d be great if Cecil could find out sooner. He could add an
elsebranch that prints out that a branch is missing. But even with that, he wouldn’t know until his code is running.What if Kotlin could tell him right away, with a compile-time error message? In other words, if he forgets to add a branch to a
when, he’d love to know before the code ever runs—and well before any customer could be affected! Thankfully, Kotlin has a feature that can solve this problem!Introduction to Sealed Types
As you might recall, when a conditional accounts for every possible case, then we say that the conditional is exhaustive. As we saw back in Chapter 5, we can use enum classes to ensure that our
whenconditionals are exhaustive.For example, if Cecil wants to describe the different sizes of ice packages, he could write something like this.
when (size) { Size.CUP -> println("A 12-ounce cup of ice") Size.BUCKET -> println("A bucket with 1 quart of ice") Size.BAG -> println("A bag with 1 gallon of ice") }If one of these branches were missing from this
whenstatement, then Kotlin would give a compiler error.when (size) { Size.CUP -> println("A 12-ounce cup of ice") Size.BAG -> println("A bag with 1 gallon of ice") }ErrorThis is exactly the kind of compiler error that Cecil would love to see, but instead of a
whenconditional that checks the value of a variable, hiswhenconditional is checking the type of a variable.when (request) { is OrderRequest -> IceCubeFactory.fulfillOrder(request) is RefundRequest -> IceCubeFactory.fulfillRefund(request) }So, how can Cecil tell Kotlin that he wants this
whenstatement to be exhaustive, to make sure that there’s a branch for each subtype of theRequestinterface? The secret is to use a feature called a sealed type. Using a sealed type is easy—we just add thesealedmodifier to our interface or class declarations.To demonstrate this, let’s update Cecil’s
Requestinterface so that it’s sealed. Thesealedmodifier goes just before theinterfacekeyword, as shown here.sealed interface Request { val id: Int }When a type like
Requestis sealed, Kotlin will keep track of all its direct subtypes. That way, Kotlin can know when we’ve been exhaustive in a conditional that checks subtypes. In fact, just by adding thesealedmodifier to theRequestinterface, it caused a compiler error on thewhenstatement.object FrontDesk { fun receive(request: Request) { println("Handling request #${request.id}") when (request) { is OrderRequest -> IceCubeFactory.fulfillOrder(request) is RefundRequest -> IceCubeFactory.fulfillRefund(request) } } }ErrorPerfect! Just like Cecil wanted, Kotlin now alerts him when he forgets a branch, and since he gets this alert at compile time, he can fix it before any customers are affected. Speaking of fixing it, that’s also easy to do—just by adding a branch for
SupportRequest, the compiler error goes away.object FrontDesk { fun receive(request: Request) { println("Handling request #${request.id}") when (request) { is OrderRequest -> IceCubeFactory.fulfillOrder(request) is RefundRequest -> IceCubeFactory.fulfillRefund(request) is SupportRequest -> HelpDesk.handle(request) } } }For what it’s worth, this compiler error can also be avoided by using an
elsebranch. However, in the code above, Cecil needs the smart cast in order to send it to the help desk.And now, the help desk is receiving support requests! Cecil can rest easy, knowing that the help desk is taking care of customers like Wallace.
Sealed Classes
In the example above, we added the
sealedmodifier to an interface declaration. However, it’s also possible to add it to a class declaration. For example, instead of requiring customers to enter anidnumber on each request, Cecil could changeRequestto an abstract class and automatically assign a random number to it. Since interfaces can’t hold state, Cecil would need to change the interface to a class, like this.sealed class Request { val id: Int = kotlin.random.Random.nextInt() }With this simple change, he’s now using a sealed class instead of a sealed interface. Naturally, this change implies a few updates to the subclasses—like removing the
idproperty and callingRequest’s constructor.class OrderRequest(val size: Size) : Request() class RefundRequest(val size: Size, val reason: String) : Request() class SupportRequest(val text: String) : Request()Note that a
sealed classis, by definition, also anabstract class. This means that we can’t directly instantiate it—we can only instantiate one of its subclasses. Although it’s not an error to include both thesealedandabstractmodifiers on the same class, doing so is redundant and unnecessary. So if you use thesealedmodifier, omit theabstractmodifier.Why Is the
sealedModifier Required At All?Now, you might be wondering why we need to add the
sealedmodifier to our interface or class declaration. Why can’t Kotlin be exhaustive in thosewhenstatements without it? We’ll answer that question, but first, let’s talk about refrigerators.You probably use a refrigerator all the time. You make sure it’s plugged in, then you open the door, put something inside for the refrigerator to keep cold, and then you close the door again. A refrigerator is a household appliance—it’s a piece of equipment that’s designed for humans to interact with.
Now consider a compressor. A compressor is a major component of a refrigerator. Without it, a refrigerator won’t keep your food cold. However, as a human, you don’t directly interact with a compressor. You use a refrigerator, and the refrigerator uses its compressor.
Similar to refrigerators, some code that we write is intended for a human to interact with directly. Instead of calling this kind of software an appliance, we call it an application. On the other hand, similar to a compressor, other programs that we write are not intended to be used directly by humans—it’s intended that they’ll be used as a component of an application. This kind of software component is called a library.
Throughout this book, we’ve already been using a library called the standard library. This library includes basic classes and interfaces, functions that we used for collection processing, and lots more. In fact, just like you can’t do much with a refrigerator if it’s missing its compressor, you can’t do much with a Kotlin project if you don’t include the standard library!
It’s also possible to create a library from your own code, so that other developers can use it. For example, Cecil could take his code, compile it, and bundle it up into a library that includes his
Requestinterface, its subclasses, and theFrontDeskandIceCubeFactoryobjects.Then, if Bert from Bert’s Snips & Clips (see Chapter 7) wants to use that interface, he could include Cecil’s library in his code.
When Bert uses Cecil’s library, he’d be able to see that there’s a
Requestinterface, and could create his own subclass of it. For example, he might create aSubscriptionRequest, where his customers could subscribe to his mailing list for coupons!However, the library was already compiled before Bert started using it. So, if the
FrontDeskcode assumes there would only be three subclasses (because that’s how many there were when it was compiled) but Bert creates a fourth, then there could be a new case that thewhenconditional would know nothing about.At the point when Cecil builds his library, there’s no way for Kotlin to know all of the subclasses that Bert—or any other developers—might also create in the future when using that library.
So instead, by adding the
sealedmodifier to the interface, it prevents Bert from being able to add another subtype ofRequestwhen he uses the library. Cecil can still add one, of course, but anyone using the library will be unable to do so.Marking
Requestassealedis kind of like taking its three subclasses, putting them in an envelope, and then “sealing” the envelope so that anyone else who gets that envelope can’t put anything else inside!In summary, when we want exhaustive subtype matching, we’ll need to include the
sealedmodifier, regardless of whether we’re building an application or a library.Restrictions of a Sealed Type’s Subtype
As we’ve seen, sealed types are helpful when we want Kotlin to ensure that we exhaustively match subtypes in a
whenconditional. By design, they come with a few restrictions. Specifically, every direct subtype of a sealed interface or class:
Must be declared in the same code base. In other words, if we were to create a library out of our code, anyone using that library would be working in a different code base, and would not be able to subtype it.
Must be declared in the same package. Even in the same Kotlin project, the subtypes of a sealed type must all be in the same exact package as the sealed type itself.
For what it’s worth, these rules are relaxed compared to what they were back in Kotlin 1.0. Back then, only sealed classes were supported (sealed interfaces were added in Kotlin 1.5), and all subclasses had to be declared inside the class body of the sealed class!
Note that these limitations apply only to direct subtypes of the sealed type. We can create a subtype of a subtype of a sealed type, even if the sealed type is in another library.
For example, Bert can’t create a new direct subtype of
Request. However, he could create a subclass ofSupportRequest, as long as Cecil had marked it as open or abstract. Why are direct subtypes restricted but secondary subtypes allowed?Well, let’s look at the
FrontDeskcode again.object FrontDesk { fun receive(request: Request) { println("Handling request #${request.id}") when (request) { is OrderRequest -> IceCubeFactory.fulfillOrder(request) is RefundRequest -> IceCubeFactory.fulfillRefund(request) is SupportRequest -> HelpDesk.handle(request) } } }Let’s say Bert is using Cecil’s library, and he adds a new direct subtype of
Request, namedSubscriptionRequest. In this code, ifFrontDesk.receive()is called with an instance ofSubscriptionRequest, none of the branches in thiswhenconditional would match, so this conditional wouldn’t actually be exhaustive. That’s why Kotlin doesn’t allow that.Now, let’s say he creates a subtype of
SupportRequestcalledCouponSupportRequest. In this case, whenFrontDesk.receive()is called with an instance ofCouponSupportRequest, then the third branch would match, becauseCouponSupportRequestis a more specific kind ofSupportRequest. So, the conditional is still exhaustive in this situation.So again, the two restrictions above apply only to direct subtypes, because secondary subtypes won’t break the integrity of the conditionals.
Sealed Types vs Enum Classes
As mentioned earlier, it was way back in Chapter 5 that we first saw how Kotlin could tell us when our
whenconditionals are exhaustive, without the need for anelsebranch, as shown here.enum class SchnauzerBreed { MINIATURE, STANDARD, GIANT } fun describe(breed: SchnauzerBreed) = when (breed) { SchnauzerBreed.MINIATURE -> "Small" SchnauzerBreed.STANDARD -> "Medium" SchnauzerBreed.GIANT -> "Large" }And as we’ve seen in this chapter, Kotlin can do the same thing for sealed types.
when (request) { is OrderRequest -> IceCubeFactory.fulfillOrder(request) is RefundRequest -> IceCubeFactory.fulfillRefund(request) is SupportRequest -> HelpDesk.handle(request) }After noticing that similarity, it’s tempting to think of sealed types as a more sophisticated kind of enum class, but that comparison can be misleading. Sealed types and enum classes have some critical differences that are important to know.
First, there’s a difference between what the conditional is checking. With a sealed type, your conditional is checking subtypes of the sealed type.
With an enum class, on the other hand, the conditional is not checking types—it’s checking values.
That’s because each entry inside an enum class is an object, not a class.
Second, enum classes have a variety of built-in properties and functions that sealed classes don’t have. For example:
You can get the
ordinalproperty of an enum entry, but subtypes of a sealed type have no order.Enum classes provide the
entriesproperty, which allows us to easily iterate over its entries. A sealed type has no such property for its subtypes.1For these reasons, it’s best not to think of sealed types as a more sophisticated kind of enum class. They achieve a similar effect in conditionals, but otherwise, they have different characteristics that give each an advantage in different situations.
If you find yourself trying to decide between using a sealed type or an enum class, ask yourself what it is that you’re trying to limit. If you need to limit values, then use an enum class. If you need to limit types, then use a sealed type.
Let’s take the example of schnauzer dog breeds from Chapter 5. If we want to represent the three valid breeds of a schnauzer, then an enum class works well. The type is just
SchnauzerBreed, and its values are limited toMINIATURE,STANDARD, andGIANT.// SchnauzerBreed instances are limited to three: enum class SchnauzerBreed { MINIATURE, STANDARD, GIANT }On the other hand, if we want to represent actual schnauzers—that is, the dogs themselves rather than the breed—then a sealed type could be a better choice. This allows us to limit the types to just three subtypes.
// Subtypes of Schnauzer are limited to three: sealed class Schnauzer(val name: String, val sound: String) class MiniatureSchnauzer(name: String) : Schnauzer(name, "Yip! Yip!") class StandardSchnauzer(name: String) : Schnauzer(name, "Bark!") class GiantSchnauzer(name: String) : Schnauzer(name, "Ruuuuffff!")However, we can still create an unlimited number of instances.
// No limit on how many Schnauzer instances you can create: val dogs = listOf( MiniatureSchnauzer("Shadow"), StandardSchnauzer("Agent"), MiniatureSchnauzer("Scout"), GiantSchnauzer("Rex"), GiantSchnauzer("Brutus") // ... as many as you want ... )Both enum classes and sealed types are important. Each one serves a distinct purpose.
Summary
Well, Cecil’s Ice Shop is doing great now—handling orders, refunds, and even support tickets!
In this chapter, we learned:
- How normal types will not be exhaustively matched in a conditional.
- How sealed types will be exhaustively matched in a conditional.
- Why Kotlin requires us to use the
sealedmodifier for this feature.- How the
sealedmodifier can be applied to bothinterfaceandclassdeclarations.- The restrictions imposed on the subtypes of sealed types.
- The differences between sealed types and enum classes.
In this chapter, we saw how helpful it is for Kotlin to highlight mistakes at compile time. But what about errors that only show up when the code is running? In the next chapter, we’ll explore ways to handle those gracefully, so that our program keeps moving forward—even when things go off the rails!
Generally, you shouldn’t need to iterate over the subtypes of a sealed type. Technically, however, it’s possible to do with Kotlin’s reflection library. ↩︎