1. Previous: Chapter 15
  2. Next: Chapter 17
Kotlin: An Illustrated Guide • Chapter 16

Sealed Types

Chapter cover image

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.

How Cecil's Ice Shop works Customer Request Front Desk Ice Cube Factory R e q u e s t

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 }
Order form and a refund form ORDER ID: SIZE: CUP BUCKET BAG REFUND ID: SIZE: REASON: 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 id in the Request interface.

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) : Request
UML class diagram for the Request interface and its implementing classes. «interface» Request + id: Int RefundRequest + id: Int + size: Size + reason: String OrderRequest + id: Int + size: Size

Then, in his Kotlin code, Cecil created a FrontDesk object to receive a Request. The front desk records that it received the request by printing out its ID number.

After that, he uses a when conditional 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 IceCubeFactory object 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

Wallace is grumpy because he cannot open his bag of ice

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.

How Cecil's Ice Shop works with the addition of the support desk Customer Request Front Desk Ice Cube Factory Help Desk O r d e r R e q u e s t R e f u n d R e q u e s t S u p p o r t R e q u e s t

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 the Request interface. In addition to the id property, this new class added only a text property, where customers can write a description of the help that they need.

class SupportRequest(override val id: Int, val text: String) : Request
UML class diagram, including the new SupportRequest «interface» Request + id: Int RefundRequest + id: Int + size: Size + reason: String OrderRequest + id: Int + size: Size SupportRequest + id: Int + text: String

As 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 when conditional for the new SupportRequest type!” 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 when conditional when he adds a new subtype. He mused, “I’ve only got one when conditional 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 else branch 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 when conditionals 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 when statement, 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")
}
Error

This is exactly the kind of compiler error that Cecil would love to see, but instead of a when conditional that checks the value of a variable, his when conditional 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 when statement to be exhaustive, to make sure that there’s a branch for each subtype of the Request interface? The secret is to use a feature called a sealed type. Using a sealed type is easy—we just add the sealed modifier to our interface or class declarations.

To demonstrate this, let’s update Cecil’s Request interface so that it’s sealed. The sealed modifier goes just before the interface keyword, as shown here.

sealed interface Request {
    val id: Int
}

When a type like Request is 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 the sealed modifier to the Request interface, it caused a compiler error on the when statement.

object FrontDesk {
    fun receive(request: Request) {
        println("Handling request #${request.id}")
        when (request) {
            is OrderRequest  -> IceCubeFactory.fulfillOrder(request)
            is RefundRequest -> IceCubeFactory.fulfillRefund(request)
        }
    }
}
Error

Perfect! 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 else branch. 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 sealed modifier to an interface declaration. However, it’s also possible to add it to a class declaration. For example, instead of requiring customers to enter an id number on each request, Cecil could change Request to 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 id property and calling Request’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 class is, by definition, also an abstract 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 the sealed and abstract modifiers on the same class, doing so is redundant and unnecessary. So if you use the sealed modifier, omit the abstract modifier.

Why Is the sealed Modifier Required At All?

Now, you might be wondering why we need to add the sealed modifier to our interface or class declaration. Why can’t Kotlin be exhaustive in those when statements 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.

Human uses refrigerator, refrigerator uses compressor. Human Application Library u s e s u s e s

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 Request interface, its subclasses, and the FrontDesk and IceCubeFactory objects.

Publishing and using a library RefundRequest + id: Int + size: Size + reason: String OrderRequest + id: Int + size: Size SupportRequest + id: Int + text: String «interface» Request + id: Int Library publish use interface Request {} object FrontDesk {} object IceCubeFactory { } class MyReq : Request { }

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 Request interface, and could create his own subclass of it. For example, he might create a SubscriptionRequest, 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 FrontDesk code 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 the when conditional 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 sealed modifier to the interface, it prevents Bert from being able to add another subtype of Request when he uses the library. Cecil can still add one, of course, but anyone using the library will be unable to do so.

Marking Request as sealed is 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!

An envelope being sealed SupportRequest + id: Int + text: String RefundRequest + id: Int + size: Size + reason: String OrderRequest + id: Int + size: Size

In summary, when we want exhaustive subtype matching, we’ll need to include the sealed modifier, 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 when conditional. By design, they come with a few restrictions. Specifically, every direct subtype of a sealed interface or class:

  1. 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.

  2. 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.

The `Request` class is sealed, but its subclasses are not. This class is sealed, so its subtypes must be in the same code base and package. However, these subclasses are not sealed, so their subtypes are not subject to those restrictions. sealed class Request { val id : Int = kotlin.random.Random. nextInt () } open class OrderRequest() : Request () open class RefundRequest() : Request () open class SupportRequest() : Request ()

For example, Bert can’t create a new direct subtype of Request. However, he could create a subclass of SupportRequest, 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 FrontDesk code 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, named SubscriptionRequest. In this code, if FrontDesk.receive() is called with an instance of SubscriptionRequest, none of the branches in this when conditional would match, so this conditional wouldn’t actually be exhaustive. That’s why Kotlin doesn’t allow that.

If the receive() function could receive a SubscriptionRequest, then none of the `when` branches would match, so it would not actually be exhaustive. If this could be a SubscriptionRequest... ...then none of these cases would match! 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) } } }

Now, let’s say he creates a subtype of SupportRequest called CouponSupportRequest. In this case, when FrontDesk.receive() is called with an instance of CouponSupportRequest, then the third branch would match, because CouponSupportRequest is a more specific kind of SupportRequest. 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 when conditionals are exhaustive, without the need for an else branch, 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.

A `when` expression on a sealed type. Each branch is checking the type. These are all subtypes of Request Note the "is" keyword when (request) { is OrderRequest   -> IceCubeFactory. fulfillOrder (request) is RefundRequest  -> IceCubeFactory. fulfillRefund (request) is SupportRequest -> HelpDesk. handle (request) }

With an enum class, on the other hand, the conditional is not checking types—it’s checking values.

A `when` expression on an enum object. Each branch is checking the value. These are all instances fun describe (breed: SchnauzerBreed) = when (breed) { SchnauzerBreed. MINIATURE "Small" SchnauzerBreed. STANDARD "Medium" SchnauzerBreed. GIANT "Large" }

That’s because each entry inside an enum class is an object, not a class.

Clarifies that `SchnauzerBreed` is the type, and the entries are the values. These are object instances of that type This is the type enum class SchnauzerBreed { MINIATURE , STANDARD , GIANT }

Second, enum classes have a variety of built-in properties and functions that sealed classes don’t have. For example:

  • You can get the ordinal property of an enum entry, but subtypes of a sealed type have no order.

  • Enum classes provide the entries property, which allows us to easily iterate over its entries. A sealed type has no such property for its subtypes.1

For 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 to MINIATURE, STANDARD, and GIANT.

// 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

Enjoying this book?
Order the paperback today!

Kotlin: An Illustrated Guide is now available on Amazon See the book on Amazon

Well, Cecil’s Ice Shop is doing great now—handling orders, refunds, and even support tickets!

In this chapter, we learned:

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!


  1. 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. ↩︎

Share this article:

  • Share on Facebook
  • Share on Reddit