1. Why it’s better not to store variants as strings and flags
When a program is just getting started, it seems like the easiest thing is to store state “somehow”: as a string "ok"/"error", as a number 0/1, as two flags isOk and hasError, and sometimes even null “just in case.” On small codebases this even works—right up until the first “uh oh, what if…”.
The problem is that this approach doesn’t let the compiler help you. A string can be misspelled, flags can be set into a contradictory state, and null can suddenly show up where nobody expects it. In the end, you create a quest for yourself: “guess which values are possible.”
Let’s imagine a mini-example of a command result in a console app:
fun render(status: String, message: String): String {
return if (status == "ok") "OK: $message" else "ERROR: $message"
}
Questions that come up here (and they are not philosophical—they are practical):
What if status = "OK"? Or "success"? Or an empty string? Or "ok " with a trailing space? Who guarantees there are no other variants?
And this is where Kotlin offers a tool that literally says: “let’s list the variants in a way that the compiler can keep you in check.” That tool is sealed interface.
2. What sealed interface gives you
The word sealed can be translated as “sealed/shut.” The idea is simple: we declare an interface (a contract), but tell the compiler: there will be a limited number of implementations, and Kotlin can take them into account during code analysis.
The nicest consequence is when. We already know that when can be used as a statement (just execute branches), and it can be used as an expression (return a value). And if when is used as an expression, Kotlin requires that all cases are covered—otherwise it’s not an expression, it’s a “lottery.”
So sealed interface gives us two things at the same time:
- first—type safety (“variants” become types),
- second—compiler support (“you forgot to handle a variant—I won’t build the project”).
Keep this mental model in mind: sealed interface is a “family of variants,” and concrete implementations are “children in that family.” If a new “child” appears, the compiler will force you to update all places where you deconstruct that family via when.
3. sealed interface Command: a command model
Let’s continue developing a training console application (let’s call it ExpenseTracker). Earlier we already worked with lists/collections, strings, when, classes, and data class. Now we want the user to enter commands: add, list, help, exit.
If we store a command as a string, we return to the “what if the string is weird” problem. So let’s make the Command type as a sealed interface and list the variants.
sealed interface Command {
data class Add(val amount: Int, val category: String) : Command
class List : Command
class Help : Command
class Exit : Command
data class Unknown(val raw: String) : Command
}
Notice a few details.
First, Add is a data class, because the command has data: amount and category.
Second, List/Help/Exit are regular classes without fields. Yes, they’ll have different instances. That’s fine: the meaning of the command is defined by the type, not by how many objects we created.
Third, Unknown is a very useful variant. Beginners often try to make a “closed set” and forget that user input can be anything. As a result, they either crash, or they write else and lose the point of sealed. The Unknown variant is an honest way to say: “we handled everything, including garbage.”
4. Parsing input into Command
Try to think of parsing as a contract: “input is a string, output is one of the Command variants.” Not String?, not Pair<Boolean, String>, but a proper type.
Basic parser
fun parseCommand(raw: String): Command {
val parts = raw.trim().split(" ")
val name = parts.firstOrNull()?.lowercase() ?: return Command.Unknown(raw)
return when (name) {
"add" -> Command.Unknown(raw) // later we will parse arguments
"list" -> Command.List()
"help" -> Command.Help()
"exit" -> Command.Exit()
else -> Command.Unknown(raw)
}
}
Here we deliberately left add “unfinished” for now: we can parse its arguments, but at this moment it’s more important to see the general principle—your command becomes a type.
Notice: here when is an expression (we return a result), so we must return something in all branches. But when is still matching on a string, and a string is an infinite universe of variants—so else is appropriate and honest here.
Parsing arguments for add
To make the example more realistic, let’s improve parsing for add. Let the command look like this:
add 120 food
That is, parts[1] is the amount, parts[2] is the category. Safe parsing means toIntOrNull() plus checks.
fun parseCommand(raw: String): Command {
val parts = raw.trim().split(" ").filter { it.isNotBlank() }
val name = parts.firstOrNull()?.lowercase() ?: return Command.Unknown(raw)
return when (name) {
"add" -> {
val amount = parts.getOrNull(1)?.toIntOrNull()
val category = parts.getOrNull(2)
if (amount != null && category != null) Command.Add(amount, category)
else Command.Unknown(raw)
}
"list" -> Command.List()
"help" -> Command.Help()
"exit" -> Command.Exit()
else -> Command.Unknown(raw)
}
}
Yes, we still return Unknown—this is not a “bad command,” it’s a normal parsing result variant. We don’t pretend that input is always perfect.
5. Exhaustive when over commands
The best part begins when we handle the command. We want to write logic “depending on the command type.” This is exactly the case where when over a sealed type can be exhaustive.
Let’s add a simple expense model (in your project it can be richer, but right now the idea is what matters):
data class Expense(val amount: Int, val category: String)
Now let’s make a command handler:
fun handleCommand(cmd: Command, expenses: MutableList<Expense>): String =
when (cmd) {
is Command.Add -> {
expenses.add(Expense(cmd.amount, cmd.category))
"Added: ${cmd.amount} in category ${cmd.category}"
}
is Command.List -> "Expenses: ${expenses.size}"
is Command.Help -> "Commands: add/list/help/exit"
is Command.Exit -> "Exiting..."
is Command.Unknown -> "Unknown command: ${cmd.raw}"
}
Here’s the key point: no else is needed. Why? Because Command is a sealed interface, and the compiler knows all variants. If you forget, for example, Command.Help, Kotlin will start complaining when it sees that when is used as an expression and doesn’t cover all branches.
And there’s another nice bonus: inside the is Command.Add branch, smart cast kicks in, and cmd is treated as Command.Add, so cmd.amount and cmd.category are available without manual casts.
6. sealed interface CommandResult: success and error
When you write commands, an inevitable question appears: “did the command execute successfully or not?”. Beginners often do this:
- return a Boolean and print text separately,
- return a string that sometimes contains "ERROR: ...",
- return null on error (and then catch NullPointerException out of nowhere).
It’s much clearer to describe the result as a closed set of variants: success or error. This is literally a textbook use case for sealed interface.
sealed interface CommandResult {
val message: String
data class Ok(override val message: String) : CommandResult
data class Error(override val message: String) : CommandResult
}
Now handleCommand can return not a string, but a CommandResult:
fun handleCommand(cmd: Command, expenses: MutableList<Expense>): CommandResult =
when (cmd) {
is Command.Add -> {
expenses.add(Expense(cmd.amount, cmd.category))
CommandResult.Ok("Added: ${cmd.amount} in category ${cmd.category}")
}
is Command.List -> CommandResult.Ok("Expenses: ${expenses.size}")
is Command.Help -> CommandResult.Ok("Commands: add/list/help/exit")
is Command.Exit -> CommandResult.Ok("Exiting...")
is Command.Unknown -> CommandResult.Error("Unknown command: ${cmd.raw}")
}
And separately—rendering the result:
fun renderResult(r: CommandResult): String =
when (r) {
is CommandResult.Ok -> "OK: ${r.message}"
is CommandResult.Error -> "ERROR: ${r.message}"
}
And again: else is not needed, because the set of variants is closed. As a result, the code reads like a scenario, not like a “pile of string-based hacks.”
7. Mini main loop
Now let’s connect everything into a mini-app. It’s still simple, but it’s already nicely structured: input → parsing into Command → execution → printing the result. And nowhere do you need to guess “which values are possible.”
fun main() {
val expenses = mutableListOf<Expense>()
while (true) {
print("> ")
val cmd = parseCommand(readln())
val result = handleCommand(cmd, expenses)
println(renderResult(result))
if (cmd is Command.Exit) break
}
}
Example dialog (approximate):
> add 120 food
OK: Added: 120 in category food
> list
OK: Expenses: 1
> abracadabra
ERROR: Unknown command: abracadabra
> exit
OK: Exiting...
Notice how few “accidental” agreements there are. We have two closed sets: Command and CommandResult. And in both cases, the compiler becomes your partner: if you add a new variant, it will force you to handle it in when.
8. Useful nuances
What to choose: enum, sealed class, or sealed interface
When sealed interface shows up, people often want to ask: “why do we need it if we have sealed class or enum?”. That’s a normal question: the tools are similar, but their purposes are slightly different.
Below is a simple “by feel” table, without diving into bytecode:
| Tool | When it’s convenient | Main idea |
|---|---|---|
|
Variants are fixed and have no data (or all have the same fields) | “Enumeration of values” |
|
Variants are fixed; sometimes you want shared state/constructor | “A family of variants with a base implementation” |
|
Variants are fixed, but the contract matters more than a “base”; plus you can implement other interfaces | “Closed contract: known implementations” |
Today our Command is exactly a contract: “this is a command; it has specific variants.” We don’t need a shared constructor or shared “base” fields, so sealed interface fits naturally.
Guard conditions in when
Sometimes a single “variant” is not enough: you want to say “this is Add, but the amount must be positive.” Kotlin has guard conditions syntax for when: a branch can be not just is Type, but is Type if ....
Let’s apply this carefully to our command. For example, we’ll treat a negative amount as an error:
fun handleCommand(cmd: Command, expenses: MutableList<Expense>): CommandResult =
when (cmd) {
is Command.Add if cmd.amount <= 0 ->
CommandResult.Error("Amount must be positive")
is Command.Add -> {
expenses.add(Expense(cmd.amount, cmd.category))
CommandResult.Ok("Added: ${cmd.amount} in category ${cmd.category}")
}
is Command.List -> CommandResult.Ok("Expenses: ${expenses.size}")
is Command.Help -> CommandResult.Ok("Commands: add/list/help/exit")
is Command.Exit -> CommandResult.Ok("Exiting...")
is Command.Unknown -> CommandResult.Error("Unknown command: ${cmd.raw}")
}
The point of guard conditions is that we don’t split the logic into “when by type” plus “another if inside.” We read the branch like a sentence: “if this is Add and the amount is invalid—error.”
9. Common mistakes when working with sealed interface
Mistake #1: making things sealed, but still writing an “empty” else in key when blocks.
Sometimes people out of habit add else -> ... even where the set of variants is closed. As a result, you lose the main benefit: if a new variant appears later, the compiler will no longer force you to handle it, because “well, you have else.” That turns sealed into a decorative sticker.
Mistake #2: trying to replace Unknown with an exception or with null, and then dealing with the consequences.
Unknown is not a design weakness; it’s an honest acknowledgement of reality: user input can be anything. If you return null, you force the whole codebase to be “nullable-oriented.” If you throw an exception, you turn an ordinary user mistake into a “crash.” The Unknown variant keeps the situation under control.
Mistake #3: mixing different responsibilities in a single sealed contract.
Sometimes people try to shove everything into one sealed interface: commands, results, events, and “one more thing just in case.” Then when becomes unreadable: branches are no longer about one coherent thing. If you feel that the variants stopped being “relatives,” it’s time to split the contract.
Mistake #4: making variants with non-obvious names (Good, Bad, X1, X2).
The main power of sealed is scenario readability. When you write when (result) { is Ok -> ... is Error -> ... }, it reads like a story. If variants are named abstractly, your when turns into a riddle instead of a behavior script.
Mistake #5: creating “contradictory” models inside variants, slipping back into flag problems.
Even with sealed you can shoot yourself in the foot if inside data class Error(...) you put message: String? and start passing null, or if Ok has a field isOk: Boolean. Variants already express the meaning. Don’t re-encode meaning with flags inside them, or you’ll get a model that contradicts itself.
Mistake #6: forgetting that when can be a statement or an expression—and getting expectations mixed up.
When when is used as an expression, Kotlin requires exhaustive handling. Sometimes beginners are surprised: “why is the compiler complaining, I handled everything… I think.” A helpful habit is to immediately see whether you’re returning a value (val x = when (...)) or just executing branches. That determines the exhaustiveness requirement.
GO TO FULL VERSION