1. Command dispatching and when as an “action table”
When you write your first programs, things often look linear: ask for a number, compute, print. But pretty quickly you want to make the program “alive”: so it accepts commands like help, add 10, show, exit and doesn’t terminate after a single action. That’s the simplest possible CLI (command-line interface), and in it you need to be able to choose what to do based on the user’s text.
Imagine you’re making a mini app “Wallet v0” (no lists, no files, no databases—just practicing logic). The user enters commands, and the program either changes the current state (for example, the balance), or shows information, or prints help. The main question: how to neatly map a command string to the right action so the code doesn’t turn into a “forest of ifs” where it’s easy to get lost.
And this is where when is an ideal dispatcher: it reads like a “command table.” Kotlin explicitly positions when as a construct for multiple options, similar to switch, and emphasizes that when can be used both as a statement and as an expression.
Minimal command dispatcher using when
Once you have a normalized command, when becomes an almost perfect dispatcher: each branch is a command, and else is a clear message: “I don’t know what this is.” Such code reads well even for someone seeing it for the first time: from top to bottom—a list of supported commands.
Kotlin highlights that when compares the value to branches in order and can be used as a statement (perform an action) or an expression (return a value). For a command dispatcher, the statement style is usually a better fit: “if the command is this—do that.”
fun main() {
val command = readln().trim().lowercase()
when (command) {
"help" -> println("Commands: help, exit") // Commands: help, exit
"exit" -> println("Shutting down...") // Shutting down...
else -> println("Unknown command: $command")
}
}
What matters here is that else is at the end. This is not just “tradition”: if you put else earlier, the branches below become unreachable, and the compiler (and future you) will be unhappy.
2. Preparing input: normalization, empty input, command and argument
Input normalization: trim() and lowercase()
If you compare user input to commands “as is,” the program will get upset about spaces and letter case. The commands Help, HELP, help and help are the same thing from a human point of view. From the point of view of a “raw string,” they are four different values. That’s why before analyzing anything, it’s customary to normalize input: trim outer spaces and convert to a single case.
The most basic and almost always sufficient set of actions: trim() (remove spaces at the edges) and lowercase() (make everything lowercase). Important: trim() does not remove spaces inside the string, and that’s good—otherwise the command add 10 would turn into add10, and the argument would vanish like a sock after laundry.
Let’s look at a mini example: read a string and turn it into a “stable version” suitable for comparisons.
fun main() {
val raw = readln()
val normalized = raw.trim().lowercase()
println("raw='$raw'") // raw=' HeLp '
println("normalized='$normalized'") // normalized='help'
}
Now the program compares only normalized. This is not “magic,” it’s discipline: we standardize the data once, and then we can proceed calmly.
To feel the point of normalization, it helps to keep a table like this in mind:
| User input | After trim() | After trim().lowercase() |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Note: "add 10" does not break after lowercase()—the inner space remains, and we’ll be able to separate the argument later.
An empty string is a separate case
When the user presses Enter, sometimes they input an empty string. And this is not rare: people make mistakes, miss keys, test “what happens if…,” or a cat walks across the keyboard (cats are the world’s main QA engineers; they just don’t sign bug reports).
You should handle an empty string explicitly, because otherwise in when you’ll get “unknown command: "",” and it won’t be clear to the user what happened. Technically the program is right, but psychologically it sounds like: “unknown request: (silence).”
The simplest pattern: after trim(), check isEmpty() and produce a clear reaction—for example, continue the loop or print a hint.
fun main() {
val input = readln().trim()
if (input.isEmpty()) {
println("Enter a command. For help: help") // Enter a command. For help: help
return
}
println("Command accepted: '$input'") // e.g.: Command accepted: 'help'
}
Here we do return because the example is short. In a real “live” program (where there is a loop), you’ll more often use continue to simply move on to the next input.
Command + argument without split(): indexOf(' ') and substring(...)
Commands often come with an argument: add 10, set 100, echo hello. We could use split(" "), but we deliberately won’t go there today: first, it’s a separate topic—“string parsing” in all its glory; second, split easily produces extra details (empty parts, multiple spaces, etc.), and right now we want the most controlled, manually understandable parsing possible.
The idea is simple: find the first space. Everything to the left is the command. Everything to the right is the argument (possibly empty). If there is no space—there is no argument.
Schematically, it can be shown like this:
flowchart TD
A["raw input: ' add 10 '"] --> B["trim(): 'add 10'"]
B --> C["lowercase(): 'add 10'"]
C --> D["indexOf(' ') -> 3"]
D --> E["command = substring(0, 3) -> 'add'"]
D --> F["arg = substring(4).trim() -> '10'"]
E --> G["when(command) {...}"]
Now—practical code. We’ll extract command and arg as “straightforwardly” as possible, so it’s clear what exactly is happening.
fun main() {
val raw = readln().trim()
val spaceIndex = raw.indexOf(' ')
val command = if (spaceIndex == -1) raw.lowercase() else raw.substring(0, spaceIndex).lowercase()
val arg = if (spaceIndex == -1) "" else raw.substring(spaceIndex + 1).trim()
println("command='$command', arg='$arg'") // e.g.: command='add', arg='10'
}
A couple of nuances that usually save you from weird bugs:
- First, we additionally trim() the argument so that add 10 still yields argument 10, not " 10".
- Second, we normalize the command via lowercase() (and usually after trim()), because that’s what we’ll compare.
3. Project: “Wallet v0”
Now we’ll put everything together into a small console program that lives in a loop, accepts commands, and changes a single number—the balance. This is not the “final dream app,” but a training simulator: it teaches you to write predictable command handling and not get tangled in strings. Over the next days of the course we’ll strengthen the approach (and more powerful tools will appear then), but today our goal is clean dispatching.
Loop skeleton: read commands until asked to exit
Let’s start with the simplest: an infinite loop, read a line, command exit—exit.
fun main() {
while (true) {
val cmd = readln().trim().lowercase()
if (cmd == "exit") break
println("You entered: $cmd") // e.g.: You entered: help
}
println("Bye!") // Bye!
}
This is not when yet, but it’s the minimal “pulse” of a command-driven program.
Add a command table using when
Now replace if (cmd == "exit") with when. Important: we want exit to actually stop the loop, so inside that branch we use break.
fun main() {
while (true) {
val cmd = readln().trim().lowercase()
when (cmd) {
"help" -> println("Commands: help, show, exit") // Commands: help, show, exit
"show" -> println("Nothing to show yet") // Nothing to show yet
"exit" -> break
else -> println("Unknown command: $cmd")
}
}
}
Note: when here is used as a statement. In statement form you don’t have to cover all options, but in practice for a CLI you still need else—otherwise “silence” looks like a malfunction.
Move to “command + argument” and introduce state balance
Now add a variable balance and commands that change it. We again use the pattern “read → prepare → parse” so we don’t try toInt() on random text.
fun main() {
var balance = 0
val raw = readln().trim()
val spaceIndex = raw.indexOf(' ')
val command = if (spaceIndex == -1) raw.lowercase() else raw.substring(0, spaceIndex).lowercase()
val arg = if (spaceIndex == -1) "" else raw.substring(spaceIndex + 1).trim()
println("Balance=$balance, command=$command, arg=$arg") // Balance=0, command=add, arg=10
}
So far we’ve only “parsed the line.” Now—the most interesting part: the command defines the action, the argument provides the data for the action.
Commands that need a number: toIntOrNull() and clear messages
A numeric argument should be parsed safely. We already know toIntOrNull() and that it returns Int?, i.e., either a number or null. After that, you have two good options: either if (value == null), or when (value) with a null branch. It’s a convenient way to avoid nested checks.
Let’s make a small fragment of logic: command add, argument is a number.
fun main() {
val arg = "10"
val delta: Int? = arg.toIntOrNull()
when (delta) {
null -> println("Need a number, not '$arg'") // if arg="abc"
else -> println("OK, we will add $delta") // OK, we will add 10
}
}
Now let’s move this approach into command handling.
Almost final version of “Wallet v0”
Below is a compact version you can assemble in full on your own. It’s still short, but already genuinely useful as a template. The messages are “human-friendly”: a CLI is not a telepathy exam.
fun main() {
var balance = 0
while (true) {
val raw = readln().trim()
if (raw.isEmpty()) continue
val spaceIndex = raw.indexOf(' ')
val command = if (spaceIndex == -1) raw.lowercase() else raw.substring(0, spaceIndex).lowercase()
val arg = if (spaceIndex == -1) "" else raw.substring(spaceIndex + 1).trim()
when (command) {
"help" -> println("Commands: help, show, set N, add N, sub N, exit")
"show" -> println("Balance: $balance") // Balance: 0
"exit" -> break
"set", "add", "sub" -> {
val value = arg.toIntOrNull()
if (value == null) {
println("Need a number after '$command'") // Need a number after 'add'
} else {
balance = when (command) {
"set" -> value
"add" -> balance + value
else -> balance - value // sub
}
println("OK. Balance: $balance")
}
}
else -> println("Unknown command: $command")
}
}
println("Exit. Final balance: $balance")
}
Inside the "set", "add", "sub" branch, we used another when as an expression: it returns the new balance value. A when expression must be exhaustive (so it always returns a value), so we have an else branch (logically, it means sub here).
Yes, it’s a “double when,” but it reads fine: the outer when chooses the command, the inner one chooses the balance update formula. And most importantly: we didn’t mix command recognition and math into one mess—first we extracted command/arg, then we processed them.
4. when (value) and when { ... } in command code
At this point many people start to get confused: “Can I always use when { ... } because it looks like if?” You can, but you don’t always need to. The main rule is to choose the form that makes the code more obvious.
If you compare one value to a set of options (for example, command to strings like "help", "exit"), then when (command) reads like a command reference. If your logic is based on conditions (for example, “the command is empty,” “too short,” “starts with #”), then when { ... } is more convenient.
Mini example: input “quality” as a set of conditions.
fun main() {
val input = readln().trim()
val status = when {
input.isEmpty() -> "empty"
input.length < 3 -> "too short"
else -> "looks like a command"
}
println(status) // e.g.: looks like a command
}
And here’s when (command) for dispatching:
fun main() {
val command = readln().trim().lowercase()
when (command) {
"help" -> println("Help") // Help
"exit" -> println("Exit") // Exit
else -> println("I don't understand: $command")
}
}
In a command interface you most often combine both approaches: first when { ... } to “check and filter out junk,” then when (command) for the “command table.”
5. Typical mistakes
Mistake #1: comparing commands without normalization.
If you do when (readln()) { "help" -> ... }, then the command " help" (with a leading space) or "HELP" (uppercase) suddenly becomes “unknown.” Formally the program is right, but the user feels wrong—and stops trusting the program. It’s better to do trim().lowercase() first, and only then compare.
Mistake #2: trying to parse an argument before you’ve confirmed it’s even needed.
Sometimes beginner code does val n = arg.toInt() right after reading the line, and only then thinks about which command was entered. As a result, the help command also tries to parse an “argument” that doesn’t exist, and the program crashes. First determine the command, then decide whether an argument is needed, and only then parse.
Mistake #3: not handling an empty line.
If you don’t single out empty input as a separate case, you’ll get either “unknown command” without explanation, or weird errors if later code assumes the command is non-empty. A couple of lines with if (raw.isEmpty()) continue dramatically increases friendliness and robustness.
Mistake #4: making when branches too big and too “multi-story.”
when is good as a dispatcher, but it quickly turns into a “mini-program inside the program” if each branch contains 20 lines of logic. Today we intentionally kept branches short: command recognition is separate, balance calculation is separate. Kotlin emphasizes that branches can be blocks, and that’s convenient, but it’s not an invitation to write a novel inside one branch.
Mistake #5: forgetting else where it matters semantically.
Even if a when statement compiles without else, in a command interface “nothing happened” looks like a bug. And if you use when as an expression, the compiler will require exhaustive handling (usually via else).