---
title: 'Sealed Types and Enumerations'
source: 'https://academia.sh/en/courses/kotlin/sealed-types-and-enumerations'
course: 'The Kotlin Language'
language: en
updated: '2026-10-21T05:02:41+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

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

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.

```kotlin
// 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.

```kotlin
// 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.

```kotlin
// 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.

```kotlin
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.

```kotlin
// 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.

```kotlin
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.

```kotlin
// 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`.

```kotlin
// 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.

```kotlin
// 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.
