time

An introduction to Kotlin sealed class

Sealed classes are used to build restricted class hierarchies, in the sense that all direct subtypes of the sealed class must be defined inside the same file as the sealed class itself.

Sealed classes are useful when you want to make sure that the values of a given type can only come from a particular limited set of subtypes. They allow you to define a strict hierarchy of types. The sealed classes themselves are abstract and cannot be instantiated.

That is, we have a class with a specific number of subclasses. What we get in the end is a concept very similar to an enum. The difference is that in the enum we only have one object per type, while in the sealed classes we can have several objects of the same class. As opposed to enums, subclasses of sealed classes can be instantiated multiple times and can actually contain state.

The important thing about sealed classes is that its subclasses must be declared in the same file as the sealed class itself.

The sealed class feature allows us to define class hierarchies that are restricted in their types, i.e. subclasses. Since all subclasses need to be defined inside the file of the sealed class, there’s no chance of unknown subclasses which the compiler doesn’t know about.

The main advantage of sealed classes reveals itself if it’s used in when expressions. Let’s compare a normal class hierarchy to one of a sealed class handled in a when. First, we’ll create a hierarchy of Mammals and then put it in a method with a when:

open class Mammal(val name: String)
class Cat(val catName: String) : Mammal(catName)
class Human(val humanName: String, val job: String) : Mammal(humanName)

fun greetMammal(mammal: Mammal): String {
    when (mammal) {
        is Human -> return "Hello ${mammal.name}; You're working as a ${mammal.job}"
        is Cat -> return "Hello ${mammal.name}"
        else -> return "Hello unknown"
    }
}

The else is mandatory, otherwise, the compiler will complain. This is because it just cannot verify that all possible cases, i.e. subclasses, are covered here. It may be possible that a subclass Dog is available at any time which is unknown at compile time.

But what if we knew there wouldn’t be other Mammals in our application? We’d want to leave out the else block.

The problem of unknown subclasses can be avoided by sealed classes. Let’s modify the base class Mammal, its’ subclasses can remain the same.

sealed class Mammal(val name: String)

Now we can simply omit the else clause since the compiler can verify that all possible cases are covered because only the subclasses in the file of the sealed class exist, without exception. The method now looks as follows:

fun greetMammal(mammal: Mammal): String {
    when (mammal) {
        is Human -> return "Hello ${mammal.name}; You're working as a ${mammal.job}"
        is Cat -> return "Hello ${mammal.name}"
    }
}

If you leave any of the subclasses out, when will complain and it won’t compile. If you implement them all, you don’t need else statement. And in general it won’t be recommended because that way we’re sure that we’re doing the right thing for all of them.

The sealed class itself is implicitly abstract (thus cannot be instantiated) and its constructor is private (thus the class cannot be inherited from in another file). This already implies that all direct subtypes must be declared inside the same file. However, subtypes of subtypes may be declared anywhere. You could add abstract or concrete members to the sealed class, just like in other abstract classes.

The benefit of sealed classes is that you have more control over your class hierarchy, and the compiler uses this as well. Because all direct child classes are known at compile time, the compiler can check when expressions for exhaustiveness. Hence, you can omit the else branch whenever you cover all cases.

Sealed class rules

  • Sealed classes are abstract and can have abstract members.
  • Sealed classes cannot be instantiated directly.
  • Sealed classes cannot have public constructors (The constructors are private by default).
  • Sealed classes can have subclasses, but they must either be in the same file or nested inside of the sealed class declaration.
  • Sealed classes subclass can have subclasses outside of the sealed class file.

Sealed classes vs. enum classes

A sealed class has a limited number of direct subclasses, all defined in the same file as the sealed class itself. It’s known as sealed as opposed to final, since although some subclassing is permitted, the subclassing is extremely limited in scope.

The hope is that this technique allows programmers to take advantage of some of the flexibility of subclassing without permitting them to create massive inheritance trees which lead to terrible, incomprehensible code. There are a few key points to know about sealed classes:

  • They are abstract. This means that you can’t instantiate an instance of the sealed class directly, only one of the declared subclasses.
  • Related to that requirement, sealed classes can have abstract members, which must be implemented by all subclasses of the sealed class.
  • Unlike enum classes, where each case is a single instance of the class, you can have multiple instances of a subclass of a sealed class.
  • You can’t make direct subclasses of a sealed class outside of the file where it’s declared, and the constructors of sealed classes are always private.
  • You can create indirect subclasses (such as inheriting from one of the subclasses of your sealed class) outside the file where they’re declared, but because of the restrictions above, this usually doesn’t end up working very well.

Imagine you’re working for a company that mostly works in U.S. dollars, but also accepts payments in Euros and some form of cryptocurrency.

sealed class AcceptedCurrency {
    abstract val valueInDollars: Float
    var amount: Float = 0.0f

    class Dollar: AcceptedCurrency() {
        override val valueInDollars = 1.0f 
    }
    class Euro: AcceptedCurrency() {
        override val valueInDollars = 1.25f 
    }
    class Crypto: AcceptedCurrency() {
        override val valueInDollars = 2534.92f 
    }

    val name: String
        get() = when (this) {
            is Euro -> "Euro"
            is Dollar -> "Dollars" 
            is Crypto -> "NerdCoin"
    }

    fun totalValueInDollars(): Float {
        return amount * valueInDollars
    }
}

In the main() function, add the following lines to the bottom:

val currency = AcceptedCurrency.Crypto() 
currency.amount = .27541f 
println("${currency.amount} of ${currency.name} is "
    + "${currency.totalValueInDollars()} in Dollars")