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

Sealed Types

Chapter cover image

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 request 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 their ice cube factory, which handles the fulfillment.

How Cecil's Ice Shop works

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

Next, for the order and refund requests, he created an interface called 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 likewise, his Request interface has a property called id.

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. RefundRequest + id: Int + size: Size + reason: String OrderRequest + id: Int + size: Size «interface» Request + id: Int

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 the orders and refunds. So, in his Kotlin code, he created an IceCubeFactory 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 on to the ice cube factory, where the order will be fulfilled.

val order = OrderRequest(123, Size.CUP)
FrontDesk.receive(order)
Handling request #123
Fulfilling order #123

And of course, 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)
FrontDesk.receive(refund)
Handling request #456
Fulfilling refund #456

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 providing support for his customers. In order to help customers like Wallace, Cecil came up with plans to add a new kind of request, called a support request. Customers who need help could just fill out a support request with text about the problem that they have, 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

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 RefundRequest + id: Int + size: Size + reason: String SupportRequest + id: Int + text: String OrderRequest + id: Int + size: Size «interface» Request + id: Int

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 happened? Cecil pulled up the code for the front desk again.

object FrontDesk {
    fun makeRequest(request: Request) {
        println("Handling request #${request.id}")
        when (order) {
            is OrderRequest -> IceCubeFactory.fulfill(request)
            is RefundRequest -> IceCubeFactory.fulfill(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 in my code right now. How much easier would it be to forget a branch if I had even more of them throughout my code! It’s too bad that I only discovered this problem after a customer complained about it!”

Instead of waiting for a customer to report a problem with his code, it’d be great 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.fulfill(request)
    is RefundRequest -> IceCubeFactory.fulfill(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 a modifier called sealed 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 you’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 - Cecil just adds a branch for SupportRequest, and 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 satisfied 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 you can’t directly instantiate it - you can only instantiate one of its subclasses. The sealed modifier also implies the abstract modifier. Although it’s not an error include both of them, 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.

Throughout this book, you’ve already been using a library, called the Kotlin 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 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

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, the FrontDesk code assumed there would only be three subclasses, because that’s how many there were when it was compiled. But Bert created a fourth! So, 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.

An envelope being sealed

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!

In summary, if you want exhaustive subtype matching, you’ll need to include the sealed modifier, regardless of whether you’re building an application or a library.

Restrictions of a Sealed Type’s Subtype

As we’ve seen, sealed types are helpful when you want Kotlin to ensure that you 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 you were to create a library out of your 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. If you want to create a subtype of a subtype of a sealed type, you can do so, even if the sealed type is in a library that you’re using.

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 marks 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, called 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. So, Kotlin does not 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)
}

It’s tempting to see that similarity and conclude that sealed types are just a more sophisticated kind of enum class, but that comparison is usually more confusing than helpful. 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() function, which allows you to easily iterate over its entries. A sealed type has no such function 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 you 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 you want to represent actual schnauzers - that is, the dogs themselves rather than the breed - then consider a sealed type. This allows you 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!")

…but it allows you to 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 in its own way!

Summary

Enjoying this book?
Pick up the Leanpub edition today!

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

Well, Cecil’s Ice Shoppe is doing great now, handling orders, refunds, and even support tickets! In this chapter, we learned:

In this chapter, we saw a few examples of compile-time errors. In the next chapter, we’ll look at different ways that we can handle errors that happen while our code is running!


  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 Twitter
  • Share on Facebook
  • Share on Reddit