Pocket Studio Academy
HomePart 11.16

Enums and sealed classes

Full course10 min read·4 questions

Some things can only be one of a few options. Enums and sealed classes let you say so — and then the compiler refuses to build your app until you have handled every single one.

The typo that compiles

A focus timer is either running a work session or running a break. Written with what you know so far, that might be:

Playground.ktkotlin
1var phase = "focus"
2
3if (phase == "brake") {
4  println("Time for tea")
5}

Read the third line again. brake, not break. This compiles. It runs. It never prints anything, ever, and there is no error message to help you — because as far as Kotlin is concerned, "brake" is a perfectly good piece of text that simply never matches.

are terrible at describing choices. There are billions of possible strings and only two of them mean anything to you. The other billions are all typos waiting to happen.

This lesson is about making the your proofreader instead.

Think of it like this

A traffic light has exactly three lamps: red, amber, green. You cannot install a purple one. The set is fixed, and everyone who deals with a traffic light knows the complete list.

That is an enum.

Now think about tickets at the same station. There is a child ticket, an adult ticket, and a group ticket — and a group ticket carries an extra fact the others do not have: how many people. Still a fixed, known list of kinds. But each kind carries different information.

That is a sealed class.

The valuable part of both is the same: because the list is closed, an inspector can check that you have thought about every case.

Enums: a small, fixed set of names

Playground.ktkotlin
enum class Phase { FOCUS, BREAK }

That declares a brand-new . A value of type Phase can be Phase.FOCUS or Phase.BREAK, and nothing else in the universe. Writing Phase.BRAKE does not compile — the typo that survived above is now caught in the editor.

By convention the names are written in capitals. Each one is an that already exists; you never build them.

Playground.ktkotlin
1val p = Phase.FOCUS
2println(p)
3println(p.name)
4println(Phase.entries)
text
1FOCUS
2FOCUS
3[FOCUS, BREAK]

entries gives you the complete list in declared order, which is perfect for a for loop. (In code written before 2023 you will see Phase.values() doing the same job.)

Enums can carry data and behaviour

An enum constant is allowed a , just like any other class:

Playground.ktkotlin
1enum class Phase(val minutes: Int) {
2  FOCUS(25),
3  BREAK(5)
4}

Now Phase.FOCUS.minutes is 25. The lengths live with the phases they describe instead of being scattered through your code as loose numbers.

The payoff: when becomes exhaustive

Here is why any of this matters.

Playground.ktkotlin
1fun advice(p: Phase): String = when (p) {
2  Phase.FOCUS -> "Head down."
3  Phase.BREAK -> "Stand up and stretch."
4}

Look at what is missing: there is no else. With a over a you would need one, because there is always another possible string. Over an enum there is not — you have covered the entire list, so Kotlin accepts it. That is called being .

Now suppose the app grows and you add a third phase:

Playground.ktkotlin
enum class Phase { FOCUS, BREAK, LONG_BREAK }

The build stops immediately:

text
1e: 'when' expression must be exhaustive, add necessary
2'LONG_BREAK' branch or 'else' branch instead

Read that as good news. You changed one thing in one file, and the compiler walked your entire project and pointed at every single place that now needs a decision. Nothing is silently wrong. Nothing gets forgotten. This is the closest thing programming has to a safety net.

Careful

An else branch throws that safety net away. Once a when has else, adding a new option compiles quietly and falls into the catch-all — which is exactly the bug the enum was meant to prevent.

Over an enum or a sealed class, prefer listing every case. Save else for when over numbers and text, where the list genuinely is endless.

Sealed classes: when each option carries different data

Enums are perfect when the options are just names. They fall apart the moment one option needs to carry something the others do not.

Think about a screen loading a list of notes. It is in exactly one of three states:

  • Loading — nothing to say yet.
  • Ready — and here are the notes.
  • Failed — and here is what went wrong.

Only two of those carry data, and they carry different data. An enum cannot express that. A can:

Playground.ktkotlin
1sealed class Screen {
2  object Loading : Screen()
3  data class Ready(val count: Int) : Screen()
4  data class Failed(val why: String) : Screen()
5}

The word sealed means: this is the complete list of possible kinds, and no one can add another from outside this file. That closed list is what lets the compiler check your work.

Each entry is a — the : Screen() after the name says "this counts as a Screen too".

object Loading is new. It means there is exactly one Loading and it holds no information, so you never build one — you just write Screen.Loading. Ready and Failed are ordinary , built with brackets like anything else: Screen.Ready(3).

Checking which one you have

Playground.ktkotlin
1fun describe(s: Screen): String = when (s) {
2  Screen.Loading -> "Loading..."
3  is Screen.Ready -> "Ready: ${s.count} notes"
4  is Screen.Failed -> "Failed: ${s.why}"
5}

is asks "is it this kind?". And inside that branch, something excellent happens: s.count just works. The compiler followed the check and now knows exactly what s is — the same that made if (name != null) work in Lesson 1.13.

Screen.Loading needs no is, because there is only one of it, so a plain equality check is enough.

And again there is no else. Add a fourth state — say object Empty : Screen() — and every when over a Screen in your whole project stops compiling until you say what an empty screen should show. Part 6 has a whole lesson on empty and error states, and this is the mechanism that stops you shipping without them.

Tip

In real Android code you will more often see sealed interface than sealed class. For everything in this course the two behave the same way — closed list, exhaustive when. Part 4 uses one for the state of a whole screen.

Which to use

SituationUse
A fixed list of plain namesenum class
A fixed list where the options carry different datasealed class
An open-ended value like a name or a messagePlain String
Try it in Pocket Studio
  1. Open Pocket Studio, tap Projects, and open your Kotlin practice project.
  2. Tap Editor and open Playground.kt.
  3. Select everything in the file and delete it.
  4. Type the program from the walkthrough above.
  5. Tap Run and read the Output panel.

You should see exactly this:

text
1FOCUS lasts 25 min
2BREAK lasts 5 min
3Loading...
4Ready: 3 notes
5Failed: no disk
  1. Now feel the safety net. Add one line inside the Screen braces: object Empty : Screen().
  2. Tap Run. The build fails before anything happens, with 'when' expression must be exhaustive, add necessary 'Empty' branch or 'else' branch instead.
  3. Fix it properly by adding Screen.Empty -> "Nothing here yet" to the when, then run again. Five lines of output as before — and now a state you cannot forget about.
Error Doctor5 common errors
e: 'when' expression must be exhaustive, add necessary 'BREAK' branch or 'else' branch instead
MeansYour when is being used as a value — its result is returned or assigned — and one of the possible cases has no branch. The message names exactly which one.
FixAdd the missing branch. Reach for else only if the missing cases genuinely all deserve the same answer, because else switches this check off for good.
e: Sealed types cannot be instantiated
MeansYou wrote Screen(). The sealed class itself is only a label for the family — the real things are its subclasses.
FixBuild one of the subclasses instead: Screen.Ready(3), or just refer to Screen.Loading.
e: This class does not have a constructor
MeansYou wrote Screen.Loading() with brackets. Loading was declared with object, so exactly one of it already exists and there is nothing to construct.
FixDrop the brackets: Screen.Loading.
e: Unresolved reference: count
MeansYou used s.count outside an is Screen.Ready branch. At that point the compiler only knows s is some kind of Screen, and not every Screen has a count.
FixMove the line inside the matching branch, so the smart cast applies. Trying to reach a property that only some of the family have is a design smell, not just a syntax problem.
e: Unresolved reference: FOCUS
MeansEither the constant is spelled differently in the enum, or you wrote it in lower case. Kotlin is case-sensitive, and enum constants are conventionally capitals.
FixMatch the declaration exactly: Phase.FOCUS. Type Phase. in the editor and Pocket Studio will list every valid constant.
Recap
  • An declares a fixed list of named options. Constants can carry values and entries lists them all.
  • A declares a fixed list of kinds, where each kind can carry different data. object for the ones with nothing to carry, data class for the rest.
  • Over either of them, a with a branch for every case is — no else required, and no case forgotten.
  • Adding a new case breaks the build everywhere it now needs handling. That is the feature, not a nuisance.
  • is matches a kind and gives you a inside the branch.
  • Next: lambdas — what those braces you have been handing to filter really are, and how to write functions that take other functions.