1. How catch is chosen and why order matters
When you first get comfortable with try/catch, you really want to do this: “okay, I’ll catch everything and print one message.” It feels like armor, like a magic shield against all troubles. The problem is that it’s a cardboard shield: yes, the program will stop crashing, but you’ll stop understanding what’s happening, and the user will get “error” with no hints about what exactly they did wrong.
In Kotlin, a catch handler runs only if an exception actually occurred inside the try, and Kotlin chooses the first matching catch from top to bottom. And “matching” means not only an exact type match, but also a match by hierarchy: if you catch a more general exception class, it will “cover” more specific ones too. This is crucial for today’s topic: the order of catch blocks determines the program’s behavior.
To feel the value of multiple catch blocks, imagine a simple console program where the user enters a number. The errors can be different: they might type "12a" (format), they might type 0 (division by zero), or they might enter index 100 for an array of three elements (bounds). If you report all of that with one phrase, you get a sad little quest: “guess what’s wrong.”
The “first match” rule
Now there will be a bit of “formal math”, but without it it’s easy to write code that looks correct… and doesn’t work the way you think.
You can think of handler selection like this:
flowchart TD
A[Code in try] --> B{Did an exception occur?}
B -->|no| C[Skip catch, move on]
B -->|yes| D[Compare with 1st catch]
D --> E{Does the type match?}
E -->|yes| F[Execute this catch]
E -->|no| G[Try the next catch]
G --> E
Key point: the checks go top to bottom, and only the first matching handler will run.
Why is this related to hierarchy? Because exceptions have “parents”: for example, NumberFormatException is a specific kind of error, and Exception is a very general “parent” for many runtime errors. If you place the handler for the “parent” too early, it will take control, and more specific handlers will never get a chance.
Specific first, general later
A very typical beginner move looks like this: “let’s catch Exception first, just to be sure…”. Kotlin usually answers quite strictly: “buddy, below you have a catch for NumberFormatException, but it will never run, because Exception will catch everything earlier.” And that’s good: the compiler literally saves you from broken logic.
Let’s look at an example with the wrong order. It’s short, but telling:
fun main() {
try {
println(readln().toInt())
} catch (e: Exception) {
println("Any error")
}
catch (e: NumberFormatException) { ... } // not allowed: unreachable
}
This code demonstrates the principle: a general catch (e: Exception) should be the “last line of defense”, if you decide to write it at all.
And here is a variant where the order is reasonable: first we catch the specific cause, and then the most general option, in case something unexpected happens.
fun main() {
try {
println(readln().toInt())
} catch (e: NumberFormatException) {
println("Error: you must enter an integer") // e.g.: "12a"
} catch (e: Exception) {
println("Unexpected error: ${e.message}")
}
}
Note that e.message can be null, and that’s normal (not all exceptions must have text). But even with null, you at least understand: “something went off-plan, and it’s not a format error.”
By the way, if you are a Java programmer: Kotlin does not require you to catch exceptions explicitly (in Kotlin they are “unchecked”), but it allows you to do so if you want to turn a crash into controlled behavior.
2. Granularity: how precisely to catch exceptions
Granularity is a word that sounds like a fancy coffee brand, but it means a simple thing: how finely you distinguish between causes of errors.
You can catch very specifically: separately NumberFormatException, separately ArithmeticException, separately ArrayIndexOutOfBoundsException. Then you can give the user the most precise hint possible. But if you write 30 lines inside each catch, the program turns into a three-volume novel, “Suffering of Handlers.” And yes, novels can be interesting, but the compiler doesn’t read them.
You can catch too generally: one catch (e: Exception) and print “Error.” Then the program doesn’t crash, but you lose diagnostics and the ability to explain to the user what exactly is wrong. At that point the app becomes like an ATM that says “ERROR” and stares at you with disappointment.
A healthy middle is usually this: expected, understandable errors are handled specifically; unexpected ones are either not caught at all (let it crash and show a stack trace, especially in learning programs), or caught by a final catch (e: Exception) and printed as a neutral message, without pretending we “fixed everything.”
A small table for orientation:
| Approach | What we write | What we end up with |
|---|---|---|
| Too general | catch (e: Exception) and one message | “Doesn’t crash”, but unclear why |
| Too fragmented | 10 different catch blocks + complex logic | Accurate, but bulky and hard to maintain |
| Practical | 2–4 specific ones + a general one at the end | Clear to the user, and the logic reads well |
Handling strategy: expected vs. unexpected errors
Now we’ll pull it into one idea: multiple catch blocks are not “because it looks nicer”, but because different causes require different actions.
If the user typed "12a" instead of a number, that’s an expected input error. The program should calmly explain that they need to enter digits and, possibly, ask them to try again (you already know how to do that with loops). It’s not a catastrophe; it’s just the human factor: people have fingers.
If the user entered divisor 0, that’s also an expected situation. Yes, you can’t divide by zero, but it’s easy to explain.
If you suddenly get an ArrayIndexOutOfBoundsException, the situation is two-sided. Sometimes it’s also an “expected” error (if the user enters the index). But sometimes it’s a pure bug in the program: you computed the index incorrectly yourself. And here, an overly general catch can mask the real issue, making you spend hours searching why “the program sometimes behaves strangely.”
So strategically it’s useful to ask yourself a simple question: who is at fault?
If it’s the user’s fault (input), print a friendly message and stay alive. If it’s the code’s fault (a bug), it’s better for the developer to see it. In learning programs it can be more useful not to catch such an exception at all, so you get a stack trace and quickly find the line where you made a mistake.
3. Practical example: the “Grades” mini-program
Let’s continue with practice in the style of “console mini-apps” we’ve been building since the first days: input, simple logic, output. Suppose we have an array of grades, and the user wants to view a grade by index and compute “10 / d” (to intentionally have a chance to catch division by zero). Yes, it’s a contrived example, but it honestly trains several different exceptions.
First, let’s create a small data skeleton:
fun main() {
val grades = intArrayOf(5, 4, 3)
print("Grade index: ")
val rawIndex = readln()
print("Divisor: ")
val rawDiv = readln()
// we'll add try/catch next
}
Now let’s add try and multiple catch blocks. Note: inside try we immediately have three places where something can go wrong: toInt() (format), grades[i] (bounds), 10 / d (division by zero). That means it makes sense to distinguish the causes.
fun main() {
val grades = intArrayOf(5, 4, 3)
try {
val i = readln().toInt()
val d = readln().toInt()
println("Grade = ${grades[i]}")
println("10 / d = ${10 / d}")
} catch (e: NumberFormatException) {
println("Error: input must be an integer")
} catch (e: ArrayIndexOutOfBoundsException) {
println("Error: index out of range 0..${grades.lastIndex}")
} catch (e: ArithmeticException) {
println("Error: division by zero")
}
}
Two things matter here.
First: we catch three different exceptions and give three different messages. That’s the practical value of multiple catch blocks.
Second: order. There is no Exception here, but the “specific first” idea still applies. And if we added a general handler, it would have to be last, because otherwise it would “eat” the others.
4. Minimize try: split handling into blocks
Now let’s do a small refactor with thinking, not “architecture of the century.” A common recommendation: try should be as small as possible—cover only what can actually fail and what you truly want to handle.
In the previous example we covered a lot at once. That’s fine for learning “multiple catch blocks.” But in real life it can be more useful to separate reading the index from computations. Then you understand more precisely which operation failed, and the code becomes easier to test by eye.
For example, you can do this: one try for parsing numbers, a second for array access and division. We don’t have to do it this way, but as a thinking tool it helps.
fun main() {
val grades = intArrayOf(5, 4, 3)
val i = try { readln().toInt() }
catch (e: NumberFormatException) { return }
val d = try { readln().toInt() }
catch (e: NumberFormatException) { return }
try {
println(grades[i])
println(10 / d)
} catch (e: Exception) {
println("Computation error: ${e.message}")
}
}
Here I intentionally used return for invalid input. It’s not “ideal UX”, but it’s a neat demonstration: if the input is invalid, there’s nothing to do further, because we don’t have valid numbers. You already know that return lets you exit a function quickly.
And yes, there is a general catch (e: Exception) in the second block. This move is sometimes acceptable as a “last line of defense”, but you should use it deliberately and keep it simple.
5. Messages in catch and the general handler
What to tell the user
Once you can catch different exceptions, a new danger appears: starting to write “real application logic” inside catch, as if it were a regular if branch. You can do it, but it quickly hurts readability: the main scenario becomes jagged because the logic spreads across handlers.
In a learning console program it’s usually enough for catch to do three things: briefly explain the problem, hint at the correct input, and return the program to a controlled state (for example, terminate or ask for input again).
For example, if you catch NumberFormatException, it’s useful to say “you need an integer”—not to dump a wall of “java.lang.NumberFormatException: For input string: ...” to the user. That message is pleasant only for someone who wanted to see a stack trace (i.e., you while debugging), not for the user.
A general catch as a safety net, not a trash bag
A general catch (e: Exception) is like a big “misc” bag in a closet. Sometimes you need it, but if you store everything there, you stop understanding what you have and where.
If you add a general catch, keep two rules in mind.
First, it must be last; otherwise more specific handlers become unreachable because the general handler will intercept exceptions earlier.
Second, it must not pretend it “fixed everything.” If you catch absolutely everything, your message should be honest and neutral: “unexpected error”—and maybe e.message. You don’t have to “heal” what you don’t understand.
Here is a typical template for a learning console program:
fun main() {
try {
println("Result: ${100 / readln().toInt()}")
} catch (e: NumberFormatException) {
println("Error: you need an integer")
} catch (e: ArithmeticException) {
println("Error: division by zero")
} catch (e: Exception) {
println("Unexpected error: ${e.message}")
}
}
Here the first two catch blocks are “expected” errors for this scenario, and the last one is a safety net.
6. Typical mistakes when working with multiple catch blocks
Mistake #1: putting catch (e: Exception) first.
It looks like “I’ll make it universal,” but conceptually it breaks the whole idea of multiple handlers. A more general type catches more specific cases, and as a result your neat catch (e: NumberFormatException) either becomes unreachable (the compiler will complain), or you simply never see it in action. The right habit is to place catch blocks from the most specific to the most general.
Mistake #2: using the same text in every catch.
If in each handler you print “Error,” you formally wrote multiple catch blocks, but they bring zero practical benefit. The point of branching is that reactions differ: “not a number” and “division by zero” are different hints to the user, and you should separate them at least by text.
Mistake #3: turning catch into a “second main” with complex logic.
In handlers, a beginner sometimes starts writing half the program: re-entering data, recreating arrays, running loops, and printing 40-line reports. That makes code hard to read: the main scenario falls apart into pieces. It’s better when catch is short, and complex logic lives in normal functions and the normal control flow.
Mistake #4: catching an exception “just in case” and silently swallowing it.
An empty catch is like covering the “Check Engine” light with tape: the car doesn’t stop breaking, you just stop finding out about it. Even in a learning program, the minimum is to print a clear message. Otherwise you lose diagnostics and later wonder why “sometimes it does nothing.”
Mistake #5: confusing “where it happened” with “where it was handled.”
An exception is thrown on a specific line inside try, but handled in catch. If you don’t keep that link in your head, you get weird expectations like “I’ll write catch, and the program will continue from the same line.” No: the line where the exception occurred does not “finish”; execution jumps into catch. That’s why after try/catch it makes sense to keep code such that it works correctly both in the success scenario and in the error-handling scenario.
Mistake #6: an overly wide try that hides the source of the problem.
When try covers “half the program,” you lose track of which operation can fail and what error you’re actually handling. This is especially unpleasant if there are many different actions inside try: input, computations, array work. Try to wrap a small fragment that you can describe in words: “here I parse a number,” or “here I access an array element.”