JavaRush/Courses/Kotlin SELF/Custom exceptions and root cause

Custom exceptions and root cause

Open

1. Why a “technical exception” is often useless

If you are just getting started, exceptions often look like “red letters that make life difficult”. But the problem is not that an exception happened; the problem is that the exception message often says nothing about your task. The computer honestly reports: “I couldn’t turn a string into a number”, and you sit there thinking: “OK… but where exactly, why exactly, and what did the user enter?”.

Imagine you have a console app where the user enters commands, for example:

add coffee 120

If the user enters:

add coffee twelve

then a typical low-level explosion will be NumberFormatException. The problem is that this “not a number” message is true, but too generic. In your app, something else matters more: “the expense amount must be a number”, and ideally you also show which value came in.

That is, we want the error to sound like a (kind) teacher: “The expense amount must be an integer, but you entered twelve”. Not like a compiler (also kind, it just has a personality): “NumberFormatException”.

2. cause and the chain of causes

When you catch an exception and decide to throw another one—a more understandable one—you risk making a classic mistake: losing the root cause. And the root cause (on the JVM it is usually called cause) is the “original crash” that started everything.

In Kotlin/Java, an exception is an object that can contain a reference to another exception: “I failed because this failed”. Kotlin documentation emphasizes that you can create your own messages while still preserving the original cause, so that it ends up in the stack trace.

Visually, it looks like a chain:

CommandParseException("Amount must be a number, got 'twelve'")
    caused by NumberFormatException("For input string: \"twelve\"")

And this chain is gold for debugging. Even if you show the user only the top level (“amount must be a number”), it is very useful for the developer (that is, future you, fixing a bug at 2 a.m.) to know the exact root cause.

A small flow diagram (yes, this is the moment when a flowchart actually helps):

flowchart TD
    A["Low-level code: toInt()"] -->|throws| B["NumberFormatException"]
    B --> C["catch(e)"]
    C --> D["throw CommandParseException(..., cause=e)"]
    D --> E["upper level catches CommandParseException"]
    E --> F["user sees a clear message"]
    E --> G["developer can see the cause chain if needed"]

3. How to declare your own exceptions

When we say “custom exception”, it does not mean “urgently build a hierarchy of 48 classes”. Usually, 1–2 classes per application layer is enough.

In Kotlin, a custom exception is a regular class that extends Exception (or another exception). Kotlin documentation describes creating custom exceptions exactly this way.

The most practical template for our purposes is to accept message and an optional cause:

class CommandParseException(
    message: String,
    cause: Throwable? = null
) : Exception(message, cause)

Note two points.

First: we use Throwable?, not Exception?. This is more idiomatic in the JVM world: cause can be any “throwable” object.

Second: the default null lets you throw an exception without a root cause when you created it “from scratch”.

Why you should not do object MyException : Exception(...)

Sometimes a beginner sees that Kotlin supports object and thinks: “Oh! I’ll make one exception for everyone, so I don’t create objects.” This is a bad idea. Exceptions are state plus a stack trace, and Kotlin documentation explicitly advises against creating exceptions via object. Create a new instance each time so the state reflects the real context.

4. Wrapping: caught, added context, rethrown

Let’s reinforce the key technique for this level: we catch a low-level error and throw a new one, but already “in the language of our application”. At the same time, we must pass cause.

Example: parsing an expense amount and adding meaning

fun parseAmount(text: String): Int {
    val prepared = text.trim()

    try {
        return prepared.toInt()
    } catch (e: NumberFormatException) {
        throw CommandParseException("Amount must be an integer, got '$text'", e)
    }
}

What matters here is that we do not hide the root cause. We added a clear message, but preserved e as the cause.

Example: a “wrapper without a cause” — how to lose the evidence

Sometimes people write this:

fun parseAmountBad(text: String): Int {
    return try {
        text.trim().toInt()
    } catch (e: NumberFormatException) {
        throw CommandParseException("Bad amount") // cause lost
    }
}

This version is unpleasant because if somewhere above you want to understand what exactly was passed in and why toInt() failed, you no longer have the original exception and its details.

Example: when context matters more than the exception “type”

Technically, NumberFormatException is already “about numbers”. But in your app you may have multiple numbers: amount, limit, quantity, month number, user ID. Then “not a number” is useless. That is why we make CommandParseException: to separate the meaning—“command parsing error”.

5. Mini app: an expense tracker and clear errors

Now we will assemble a small but complete piece of our learning app. Let it be the simplest expense tracker: it stores a list of expenses in memory and accepts commands:

add <title> <amount>
list
exit

We will not complicate the architecture with layers up to the ceiling—today’s goal is not an “ideal system”, but practice: where exactly to wrap an error and how not to lose the cause.

Data model and in-memory storage

In previous days we already had classes and collections of objects, so we make a simple model:

data class Expense(
    val title: String,
    val amount: Int
)

And a list as storage:

val expenses = mutableListOf<Expense>()

Custom exceptions at the application level

We will define two types of exceptions: one for command input errors, the other for logically impossible situations (even though the second one will barely be needed today, it is useful to see the difference).

class CommandParseException(message: String, cause: Throwable? = null) : Exception(message, cause)

class AppStateException(message: String, cause: Throwable? = null) : Exception(message, cause)

Command parser: return a structure or throw an exception

To keep the code readable, it is convenient to represent a parsed command as a type. We already know sealed class, so we will do it like this:

sealed class Command {
    data class Add(val title: String, val amount: Int) : Command()
    data object ListAll : Command()
    data object Exit : Command()
}

Now the parsing function:

fun parseCommand(line: String): Command {
    val parts = line.trim().split(" ").filter { it.isNotBlank() }

    if (parts.isEmpty()) throw CommandParseException("Empty command")

    return when (parts[0].lowercase()) {
        "add" -> parseAdd(parts)
        "list" -> Command.ListAll
        "exit" -> Command.Exit
        else -> throw CommandParseException("Unknown command: '${parts[0]}'")
    }
}

Here we throw CommandParseException “directly” in cases where there was no low-level root cause: we detected the problem ourselves.

And now the most interesting part—parseAdd.

parseAdd: wrap NumberFormatException into a clear error

fun parseAdd(parts: List<String>): Command.Add {
    if (parts.size < 3) {
        throw CommandParseException("Usage: add <title> <amount>")
    }

    val title = parts[1]
    val amountText = parts[2]

    val amount = try {
        amountText.toInt()
    } catch (e: NumberFormatException) {
        throw CommandParseException("Amount must be a number, got '$amountText'", e)
    }

    return Command.Add(title = title, amount = amount)
}

Notice: we could have called our parseAmount(...), but here we intentionally show the technique right at the usage site, so you can see “caught → rethrown with cause”.

Main loop: catch only what you expect

Now main. By the style we discussed earlier, we do not want to catch “everything”. We catch only expected parsing errors.

fun main() {
    val expenses = mutableListOf<Expense>()

    while (true) {
        print("> ")
        val line = readln()

        try {
            when (val cmd = parseCommand(line)) {
                is Command.Add -> {
                    expenses.add(Expense(cmd.title, cmd.amount))
                    println("Added: ${cmd.title} (${cmd.amount})") // Added: coffee (120)
                }
                Command.ListAll -> {
                    printExpenses(expenses)
                }
                Command.Exit -> return
            }
        } catch (e: CommandParseException) {
            println("Input error: ${e.message}") // for example: Input error: Amount must be a number, got 'twelve'
            // For learning purposes, you can temporarily print the root cause:
            // println("Cause: ${e.cause?.javaClass?.simpleName}") // Cause: NumberFormatException
        }
    }
}

We will keep printExpenses short and safe:

fun printExpenses(expenses: List<Expense>) {
    if (expenses.isEmpty()) {
        println("(empty)") // (empty)
        return
    }

    for (e in expenses) {
        println("- ${e.title}: ${e.amount}") // - coffee: 120
    }
}

6. require/check vs custom exceptions

At some point you may feel: “Why do I need custom exceptions if I have require()?” A good question, because this is where the mature line between “validation” and “context” appears.

require(...) and check(...) are predicates that throw standard exceptions: IllegalArgumentException and IllegalStateException. Kotlin documentation describes their purpose well and which exceptions they throw.

In plain terms, require is “you gave me bad input, I’m not obligated to work”, and check is “I have a bad internal state; this is a logical error in the code”.

Custom exceptions are most often needed elsewhere: when you want to separate a meaningful error of your domain. For example, “command not recognized”, “cannot add an expense with a negative amount”, “category does not exist”. In such cases, your own exception type helps you:

  • first, catch it separately (and not confuse it with others),
  • second, provide a consistent message style,
  • third, preserve the cause if the error came from “below”.

For example, if you want to forbid negative amounts, you can do it like this:

fun validateAmount(amount: Int): Int {
    require(amount > 0) { "Amount must be > 0, got $amount" }
    return amount
}

This is a great option because a negative amount is a precondition violation.

But if you parse a command and want to distinguish “parsing error” from all other problems, CommandParseException will read better.

Mini diagnostics: how to view the chain of causes

Sometimes you want to show the user a short message, but for yourself (or for temporary diagnostics in learning mode) print the cause chain. In Kotlin, any Throwable has a cause: Throwable?.

Let’s make a small function that prints a “staircase of causes”. No overkill, 7–8 lines:

fun printCauseChain(e: Throwable) {
    var current: Throwable? = e
    while (current != null) {
        println("${current::class.simpleName}: ${current.message}")
        current = current.cause
    }
}

If you call:

try {
    parseCommand("add coffee twelve")
} catch (e: CommandParseException) {
    printCauseChain(e)
}

then you will see something like:

CommandParseException: Amount must be a number, got 'twelve'
NumberFormatException: For input string: "twelve"

And this is a good compromise: you do not show scary details to the user, but in development mode you quickly understand what happened.

7. Common mistakes

Mistake #1: wrapping an exception and losing the cause.
When you catch NumberFormatException and throw your own exception without passing the cause, you gain a nice message but lose the ability to investigate. A couple of weeks later you will look at CommandParseException("Bad amount") and feel that this error is written as “I don’t know what happened, but it happened”. Pass the root cause as the second argument (Exception(message, cause)); Kotlin and the JVM can store this link.

Mistake #2: making one exception for every case.
Sometimes the temptation appears: “I’ll make AppException and always throw it.” As a result, the exception type stops meaning anything, and you are forced to distinguish everything by the message text again. Better to have at least one meaningful type for your layer—for example, CommandParseException for input and parsing errors—and use it consistently.

Mistake #3: creating exceptions as object to “avoid allocations”.
An exception contains a stack trace and the creation context. If you make a singleton exception, it will store the wrong context for what actually happened in the program, and diagnostics will become strange. Kotlin documentation explicitly warns that exceptions are stateful objects and must be created as new instances.

Mistake #4: catching a type that is too broad and “fixing everything in the world”.
catch (e: Exception) around half of main looks like a “protective dome”, but in practice it turns into an “error vacuum”: you may accidentally swallow bugs that should have crashed and been fixed. Today’s technique about cause helps you do it right: catch only what you expect (for example, CommandParseException), and let the rest crash—it is a signal to the developer.

Mistake #5: exception messages without actual values.
A message like "Bad amount" sounds like “something bad”. A message like "Amount must be a number, got 'twelve'" sounds like instructions on what to fix and contains data for diagnosis. Try to write messages using the formula “what was expected” + “what was received”.

Mistake #6: mixing preconditions and scenario errors into one pile.
If you use require for everything, you will quickly lose semantics: where the user input error is, where the logical bug is, where your scenario problem is. Remember that require()/check() are about input and state (and they throw standard exceptions). Custom exceptions are useful where you want to express errors in the “language of the application” and, if needed, preserve the root cause.

1
Task
Kotlin SELF,  40 level,  2 lecture
Locked
Format error
Format error
1
Task
Kotlin SELF,  40 level,  2 lecture
Locked
Item quantity
Item quantity
1
Task
Kotlin SELF,  40 level,  2 lecture
Locked
Console command
Console command
1
Task
Kotlin SELF,  40 level,  2 lecture
Locked
Cause ladder
Cause ladder
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet