Skip to content
academia.sh

Lesson 13 / 23

Sealed Types and Enumerations

A measurement of which criterion an enum entry, a data object, a data class, and an ordinary class fall to in the same sealed hierarchy, and of how an exhaustive when rejects a missing branch.

Published Updated

In this lesson

  • Measures the entries, ordinal, and valueOf behavior of an enumeration and that its entries are singletons
  • Shows that an exhaustive when over a sealed interface rejects a missing branch at compile time
  • Measures that data object, data class, and ordinary class subtypes give three separate criteria
  • Verifies with the bounding measurement that the kind of the subtype, not the hierarchy, determines the criterion
Contents

The previous lesson showed that a data class generates equals from the properties in its primary constructor and that the criterion depends on the field types. Every class measured so far, however many instances it had, was an open type: a separate instance can be built anywhere. For cases where a value can be only one of a few forms counted in advance, Kotlin offers two tools: enumerations with enum class, and sealed classes and interfaces with the sealed keyword. This lesson measures both and looks at how each one falls to the criterion.

The measurement axis stays the same: the relationship between a value and another value that counts as equal to it, the criterion. The Criterion class is given again below, as it was in the opening lesson. The rule works in order: if the first value and its twin are the same object, the criterion is singleton; if not, and the twin is not equal, it is identity; if the twin is equal but the third value is not, it is content; if the third value is equal too, it is partial content. This lesson brings the slices of the previous three lessons together in a single hierarchy.

// Criterion.kt — classifies the criterion that makes two values equal
class Criterion(val label: String, first: Any?, twin: Any?, other: Any?) {
    val sameObject = first === twin
    val equalTwin = first == twin
    val equalOther = first == other

    val name: String = when {
        sameObject -> "singleton"
        !equalTwin -> "identity"
        !equalOther -> "content"
        else -> "partial-content"
    }

    fun report() {
        println("$label: same-object=$sameObject, twin=$equalTwin, other=$equalOther -> $name")
    }
}

fun summarize(checks: List<Criterion>) {
    for (name in listOf("singleton", "identity", "content", "partial-content")) {
        println("$name: ${checks.count { it.name == name }}")
    }
}

Enumerations

An enumeration is a class all of whose instances are counted one by one in its definition. enum class Level { LOW, MEDIUM, HIGH } defines exactly three Level values, and a fourth cannot be built from outside. Each entry is the single instance of the class. entries gives all the entries in definition order, ordinal returns the position number starting from zero, and valueOf finds an entry from a string and throws an exception if it cannot.

The block below tries these. The describe function maps each entry to a string with when and has no else branch: the compiler knows that all three entries are covered. The block also asks with === whether the entry found with valueOf("MEDIUM") is the same object as the Level.MEDIUM written directly in the code. The exception thrown when an undefined name is given is caught, and only the class name is printed.

// Main.kt — enumeration
enum class Level { LOW, MEDIUM, HIGH }

fun describe(level: Level): String = when (level) {
    Level.LOW -> "low"
    Level.MEDIUM -> "medium"
    Level.HIGH -> "high"
}

fun main() {
    println(Level.entries)
    println(Level.HIGH.ordinal)
    println(Level.valueOf("MEDIUM"))
    println(Level.valueOf("MEDIUM") === Level.MEDIUM)
    println(describe(Level.LOW))
    try {
        Level.valueOf("EXTREME")
    } catch (e: IllegalArgumentException) {
        println(e::class.simpleName)
    }
}
[LOW, MEDIUM, HIGH]
2
MEDIUM
true
low
IllegalArgumentException

entries gives the three entries in order, HIGH is numbered 2 when counting from zero, and the entry found with valueOf is the same object as the one written directly. This is the first question of the criterion: the entry is its own twin, and the criterion is singleton. An undefined name throws IllegalArgumentException.

TN19. The measurement is only for an enumeration with three entries and no fields or methods. Whether the behavior stays the same when the entries have their own fields or methods, or when the enumeration implements an interface, was not measured.

The Sealed Interface and the Exhaustive When

sealed interface Shape is an interface for which all the types that can implement Shape are known in advance, in the same package and in the same compilation. The hierarchy below consists of three subtypes: data object Empty is an object declaration with a single instance, data class Circle is a data class, and class Blob is an ordinary class with no equals written. These three types gather the previous lessons into a single hierarchy. Because the compiler knows the full list of subtypes, it can check whether the when (shape) expression covers all of them.

// Main.kt — closed hierarchy
sealed interface Shape
data object Empty : Shape
data class Circle(val radius: Int) : Shape
class Blob(val size: Int) : Shape

fun describe(shape: Shape): String = when (shape) {
    Empty -> "empty"
    is Circle -> "circle " + shape.radius
    is Blob -> "blob " + shape.size
}

fun main() {
    val shapes = listOf(Empty, Circle(2), Blob(3))
    for (shape in shapes) println(describe(shape))
    println(Empty)
    println(Circle(2))
}
empty
circle 2
blob 3
Empty
Circle(radius=2)

Three branches cover three subtypes, and no else is written. println(Empty) prints just the name: a data object generates toString with the name of the object. println(Circle(2)) prints the data class form with its fields. For an ordinary object, the default toString would give a name and a hash code; the data modifier makes it readable.

The real value of the exhaustive when lies elsewhere: if a subtype is added to the hierarchy and one of the branches is forgotten, the compiler rejects it. The fragment below leaves out the Blob branch. As in the opening lesson, the fragment was measured and not compiled; the compiler gave an error that shows the missing branch by name. The error text is not printed here.

sealed interface Shape
data object Empty : Shape
data class Circle(val radius: Int) : Shape
class Blob(val size: Int) : Shape

fun describe(shape: Shape): String = when (shape) {
    Empty -> "empty"
    is Circle -> "circle " + shape.radius
}

fun main() {
    println(describe(Empty))
}

TN20. The compile-time rejection was measured on Kotlin 2.4.21. Whether the same check applies when when is used not as an expression but only as a statement was not measured in this lesson.

Enumerations with Fields

Enumeration entries can carry fields and methods. The entries take constructor values in parentheses, the entry list ends with ;, and the members follow. The block below tries an enumeration that carries a size and a code: it calls a method on an entry, transforms over entries, compares entries by position number, and looks up a code that does not exist.

// Main.kt — enumeration with fields
enum class Planet(val code: String, val size: Int) {
    SMALL("s", 1),
    MEDIUM("m", 5),
    LARGE("l", 9);

    fun isBigger(other: Planet): Boolean = size > other.size
}

fun main() {
    println(Planet.LARGE.isBigger(Planet.SMALL))
    println(Planet.entries.map { it.code })
    println(Planet.entries.maxByOrNull { it.size })
    println(Planet.MEDIUM.name + Planet.MEDIUM.ordinal)
    println(Planet.SMALL < Planet.LARGE)
    println(Planet.entries.firstOrNull { it.code == "x" })
    println(Planet.entries.firstOrNull { it.code == "x" }?.name ?: "none")
}
true
[s, m, l]
LARGE
MEDIUM1
true
null
none

LARGE.isBigger(SMALL) gives true; the method uses the entry’s own size field. entries gives the entries in definition order, so the list of codes comes out as [s, m, l]. maxByOrNull finds the entry with the largest size and prints LARGE. Joining name and ordinal gives MEDIUM1: the position of the second entry is 1. SMALL < LARGE gives true, because entries can be compared by position number. When a code that does not exist is searched for, the result is null; ?.name ?: "none" turns it into none.

The enumeration here is a closed and fixed set of values, and each entry is a single instance. Adding a field means giving values on the line where the entry is defined; a member cannot be written until the entry list is ended with ;. That the search returns null also shows the difference from valueOf: valueOf throws an exception for a name that does not exist, while firstOrNull carries the absence as a value. Which one to choose depends on whether the absence is an error or an expected situation.

An Else Branch Silences Exhaustiveness

The rejection of a missing branch by an exhaustive when is a guarantee, but the guarantee is lost the moment an else is written. The block below uses the same hierarchy that was rejected above and puts an else in place of the Blob branch. This time the compilation passes. What the block shows is what happens when a subtype is added later: that subtype silently falls into the else branch.

sealed interface Shape
data object Empty : Shape
data class Circle(val radius: Int) : Shape
class Blob(val size: Int) : Shape

fun describe(shape: Shape): String = when (shape) {
    Empty -> "empty"
    is Circle -> "circle"
    else -> "unknown"
}

fun main() {
    println(describe(Empty))
    println(describe(Circle(1)))
    println(describe(Blob(1)))
}
empty
circle
unknown

The output is empty, circle, unknown. The compilation passed, because else covers every remaining case. The Blob instance fell into the unknown branch; in the exhaustive version above, this situation was a compile error. The difference is that the else branch switches off the compiler’s check for missing subtypes.

The problem appears not in today’s code but in tomorrow’s change. A subtype added to the hierarchy later silently falls into that branch in every when expression that has an else branch, and the compiler gives no warning. In an exhaustive expression, the same addition would have flagged every relevant when expression with a compile error. A common mistake is to add an else “just in case”. The guarantee the sealed type provides comes precisely from not writing this branch. If a truly default behavior is needed, that behavior is modeled as an explicit subtype, and else does not take on this job silently.

The Sealed Class and Smart Casts

Besides the sealed interface there is the sealed class; the difference is that the class can carry constructor state. The subtypes are usually written inside the class. The Outcome below describes three states: a success that carries a value, a failure that carries a reason, and a pending state that carries nothing. The exhaustive when makes a smart cast to the subtype in each branch and reaches fields such as outcome.value directly.

// Main.kt — sealed class and smart cast
sealed class Outcome {
    data class Success(val value: Int) : Outcome()
    data class Failure(val reason: String) : Outcome()
    data object Pending : Outcome()
}

fun render(outcome: Outcome): String = when (outcome) {
    is Outcome.Success -> "value " + (outcome.value + 1)
    is Outcome.Failure -> "error " + outcome.reason.length
    Outcome.Pending -> "waiting"
}

fun main() {
    val outcomes = listOf(Outcome.Success(1), Outcome.Failure("fail"), Outcome.Pending)
    for (outcome in outcomes) println(render(outcome))
    println(Outcome.Pending === Outcome.Pending)
    println(outcomes.filterIsInstance<Outcome.Success>().size)
    println(outcomes.count { it is Outcome.Failure })
}
value 2
error 4
waiting
true
1
1

sealed class and sealed interface provide the same guarantee: the subtypes are known at compile time. The difference is that the class can carry constructor state and superclass behavior. In the output the three branches run: value 2, error 4, and waiting. In each branch the compiler narrows the outcome value to the subtype of that branch; this is why outcome.value and outcome.reason can be written directly, with no separate cast.

The fourth line asks about Pending === Pending and returns true: a data object is a single instance, so identity and content equality coincide. The last two lines filter the hierarchy with collection functions: filterIsInstance and counting with is both give 1. Nested writing puts the subtype names in the form Outcome.Success. This makes it visible that the subtypes belong to the hierarchy and prevents another type with the same name from getting mixed in. The subtypes can also be written outside, as long as they are in the same package, but nested writing shows the reader where the exhaustiveness looks.

Enumerations in Collections

Because enumeration entries are singletons, using them as keys in collections is trouble-free. Because entries are Comparable, they are sorted by position number. The block counts, sorts, and deduplicates the repeated entries in a list, then asks the order of two entries with compareTo.

// Main.kt — enumeration in collections
enum class Level { LOW, MEDIUM, HIGH }

fun main() {
    val levels = listOf(Level.HIGH, Level.LOW, Level.HIGH, Level.MEDIUM)
    println(levels.groupingBy { it }.eachCount())
    println(levels.sorted())
    println(levels.distinct().size)
    println(Level.HIGH.compareTo(Level.LOW))
    println(Level.valueOf("LOW") == Level.LOW)
}
{HIGH=2, LOW=1, MEDIUM=1}
[LOW, MEDIUM, HIGH, HIGH]
3
2
true

groupingBy { it }.eachCount() counts the entries and keeps the order of first appearance: {HIGH=2, LOW=1, MEDIUM=1}. Because entries are singletons, they are safe to use as keys; the two HIGH values point to the same instance and are collected under a single key. sorted arranges the entries by position number, and [LOW, MEDIUM, HIGH, HIGH] comes out. distinct leaves three entries. HIGH.compareTo(LOW) gives 2 in this example, a value consistent with the difference of the position numbers. valueOf("LOW") == LOW is true, because the name returns the same instance.

The mechanism here is that an enumeration is both singleton and ordered. The ordering depends on the definition order, which means the position of the entries in the file is part of the behavior. A common mistake is to insert entries in the middle later and break code that relies on the ordering: when the position number changes, both the result of sorted and the value of compareTo change. In an enumeration where the ordering matters, a new entry is added at the end, or the order is kept in an explicit field and the comparison is made over that field.

The Bounding Measurement

The three subtypes are under the same sealed interface. The seal closes who can be a subtype, but it does not determine equals. The same hierarchy should therefore give more than one criterion at the same time. The bounding measurement tries this with four lines. The first line compares an enumeration entry with its twin found by valueOf. The next three lines try the Shape subtypes in turn: Empty, Circle(1), and Blob(1). For each subtype the twin is built separately with the same value, and the value of the third is different; for Empty, Circle(1) is given as the third value because Empty has no other instance.

// Main.kt — kind of the subtype
sealed interface Shape
data object Empty : Shape
data class Circle(val radius: Int) : Shape
class Blob(val size: Int) : Shape
enum class Level { LOW, MEDIUM, HIGH }

fun main() {
    val checks = listOf(
        Criterion("entry", Level.HIGH, Level.valueOf("HIGH"), Level.LOW),
        Criterion("data-object", Empty, Empty, Circle(1)),
        Criterion("data-class", Circle(1), Circle(1), Circle(2)),
        Criterion("class", Blob(1), Blob(1), Blob(2)),
    )
    for (check in checks) check.report()
    summarize(checks)
}
entry: same-object=true, twin=true, other=false -> singleton
data-object: same-object=true, twin=true, other=false -> singleton
data-class: same-object=false, twin=true, other=false -> content
class: same-object=false, twin=false, other=false -> identity
singleton: 2
identity: 1
content: 1
partial-content: 0

The same hierarchy gives three separate criteria. The enumeration entry and the data object are singletons because each has a single instance: the twin is already the same object. Circle gives content because it is a data class. Blob has no equals written; two instances with the same value are different objects, and the criterion is identity. The seal does not determine the criterion; the kind in which the subtype is written does. Singleton, identity, and content can stand side by side under a single interface handled by a single when. The distribution is 2 singleton, 1 identity, 1 content, 0 partial content.

Summary

  • Enumeration entries are the single instances of the class: the entry found with valueOf is the same object as the one written directly, and the criterion is singleton.
  • entries, ordinal, and valueOf list the entries in definition order, order them, and find them by name; an undefined name throws IllegalArgumentException.
  • In a sealed interface the list of subtypes is known to the compiler; an exhaustive when rejects a missing branch at compile time.
  • A data object gives singleton, a data class subtype gives content, and an ordinary class subtype with no equals written gives identity.
  • This lesson’s criterion: singleton. In the bounding measurement, the same sealed hierarchy gave all three criteria at once; the kind of the subtype, not the hierarchy, determines the criterion.

Next Step

data object Empty built a single-instance object with a declaration. In Kotlin, the general form of this pattern is the object declaration: it defines the class and its single instance together. The next lesson measures object declarations, the helper object inside a class, and the object expression built without giving it a name, and looks at how each of the three falls to the criterion.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close