Custom Exceptions

Kotlin lets you define your own exception types simply by extending Exception (or one of its subclasses) like any other class. A custom exception gives a specific, meaningful name to a failure — InsufficientFundsException instead of a generic Exception with a string message — so callers can catch exactly the failure they know how to handle and let everything else propagate. This lesson covers how to declare custom exceptions, build a closed hierarchy of related failures with sealed class, preserve the original cause of an error, and the mistakes that trip up most people new to Kotlin’s exception model.

Overview / How It Works

In Kotlin, every exception is ultimately a Throwable. The two main branches under it are Error (serious problems like OutOfMemoryError that a program usually shouldn’t try to recover from) and Exception (problems your code is expected to handle or report). A custom exception is just a class that extends Exception, or a more specific subclass such as RuntimeException or IllegalArgumentException, and typically forwards a message (and optionally a cause) to its parent constructor.

A crucial difference from Java: Kotlin has no checked exceptions. In Java, a method that throws a checked exception must declare it with throws, and every caller is forced by the compiler to catch it or re-declare it. Kotlin dropped this entirely — all exceptions, custom or built-in, are unchecked. You never write throws, and the compiler never forces a caller to handle an exception. This is a deliberate design choice: the Kotlin team found that checked exceptions in large Java codebases led to either overly broad catch (Exception e) blocks or exceptions being silently swallowed just to satisfy the compiler, neither of which actually improves reliability. In Kotlin, documentation and API design (including your custom exception’s name and hierarchy) carry that responsibility instead.

When you write class MyException(message: String) : Exception(message), the compiler generates a normal class that inherits message, cause, stackTrace, and toString() from Throwable. You can add your own properties (an error code, an offending value, an entity id) exactly as you would on any class, using a primary constructor with val parameters. Because a thrown exception is just an object, you can inspect those properties in a catch block just like any other property access.

For a closed family of related failures, Kotlin’s sealed class is the idiomatic tool: it lets you declare a small, fixed set of exception subtypes that the compiler knows about completely. This matters most when you later write a when expression over that exception type — because the hierarchy is sealed, the compiler can verify you handled every subtype (exhaustiveness), catching a forgotten case at compile time instead of at runtime. Direct subclasses of a sealed class must live in the same module as the sealed class itself.

Syntax

The general shape of a custom exception declaration:

class MyException(message: String) : Exception(message)

class MyException(message: String, cause: Throwable) : Exception(message, cause)

class MyException(val errorCode: Int, message: String) : Exception(message)
  • class name — by convention ends in Exception, e.g. InvalidAgeException.
  • primary constructor parameters — at minimum a message: String; you may add your own val properties (an id, a code, an offending value) alongside it.
  • : Exception(…) — calls the parent constructor, forwarding the message and, optionally, a cause: Throwable that records what originally triggered this failure.
  • parent class choice — pick the closest matching standard type instead of always extending Exception directly; see the table below.
Base class When to extend it
Exception General-purpose parent when nothing more specific fits.
RuntimeException Failures that indicate a programming/logic error rather than an expected condition.
IllegalArgumentException A caller passed an invalid argument value.
IllegalStateException An operation was called while an object was in the wrong state.
NoSuchElementException A requested element/entry does not exist.

Examples

Example 1: A basic custom exception

class InsufficientFundsException(message: String) : Exception(message)

fun withdraw(balance: Double, amount: Double): Double {
    if (amount > balance) {
        throw InsufficientFundsException("Cannot withdraw $amount, balance is only $balance")
    }
    return balance - amount
}

fun main() {
    val balance = 100.0
    try {
        val newBalance = withdraw(balance, 150.0)
        println("New balance: $newBalance")
    } catch (e: InsufficientFundsException) {
        println("Transaction failed: ${e.message}")
    }
}

Output:

Transaction failed: Cannot withdraw 150.0, balance is only 100.0

withdraw throws InsufficientFundsException instead of returning a sentinel value or a generic exception. The catch (e: InsufficientFundsException) block only matches that specific type, so unrelated failures (a bug elsewhere throwing NullPointerException, say) would not be silently caught here — they would propagate up, which is exactly what you want.

Example 2: Adding custom properties

class InvalidAgeException(val age: Int, message: String) : IllegalArgumentException(message)

fun registerUser(name: String, age: Int) {
    if (age < 0 || age > 150) {
        throw InvalidAgeException(age, "Age $age is not valid for user $name")
    }
    println("Registered $name, age $age")
}

fun main() {
    val ages = listOf(25, -5, 200, 40)
    for (age in ages) {
        try {
            registerUser("User$age", age)
        } catch (e: InvalidAgeException) {
            println("Rejected: ${e.message} (invalid value: ${e.age})")
        }
    }
}

Output:

Registered User25, age 25
Rejected: Age -5 is not valid for user User-5 (invalid value: -5)
Rejected: Age 200 is not valid for user User200 (invalid value: 200)
Registered User40, age 40

InvalidAgeException extends IllegalArgumentException (a good fit, since the problem is a bad argument) and adds its own val age: Int property. Because it is a normal constructor parameter marked val, it becomes a readable property on the exception object, so the catch block can read e.age directly instead of trying to parse it back out of the message string.

Example 3: Preserving the original cause

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

fun parseAge(input: String): Int {
    return try {
        input.trim().toInt()
    } catch (e: NumberFormatException) {
        throw DataParseException("Could not parse age from '$input'", e)
    }
}

fun main() {
    val inputs = listOf("30", "abc", " 45 ")
    for (input in inputs) {
        try {
            val age = parseAge(input)
            println("Parsed age: $age")
        } catch (e: DataParseException) {
            println("Error: ${e.message}, caused by: ${e.cause}")
        }
    }
}

Output:

Parsed age: 30
Error: Could not parse age from 'abc', caused by: java.lang.NumberFormatException: For input string: "abc"
Parsed age: 45

Here parseAge catches a low-level NumberFormatException and re-throws a higher-level, more meaningful DataParseException — but it passes the original exception in as cause rather than discarding it. This is important: without the cause parameter, the original stack trace and exception type would be lost forever, making the eventual bug report far harder to debug. The default cause: Throwable? = null makes the parameter optional for the common case where there is no underlying exception to wrap.

How It Works Step by Step

When a throw statement runs: (1) a new exception object is constructed, capturing the current call stack into its stackTrace property; (2) normal execution stops immediately at that point — no code after the throw in the same function runs; (3) the JVM unwinds the call stack, function by function, looking for a try block whose catch clause matches the exception’s type (or one of its supertypes); (4) if a match is found, that catch block runs with the exception object bound to its parameter, and execution continues after the try/catch; (5) if no matching catch exists anywhere up the call stack, the exception reaches main unhandled and the program terminates, printing the stack trace. A finally block, if present, always runs before control leaves the try/catch, whether an exception was thrown or not.

Common Mistakes

Mistake 1: Forgetting to forward the message

class MyException : Exception() {
    // no message passed to the parent constructor
}

fun main() {
    try {
        throw MyException()
    } catch (e: MyException) {
        println("Error: ${e.message}")
    }
}

Output:

Error: null

This compiles fine, but it is nearly useless in production — e.message is null because nothing was ever passed to Exception()‘s constructor. Always forward a descriptive message:

class MyException(message: String) : Exception(message)

fun main() {
    try {
        throw MyException("Something went wrong")
    } catch (e: MyException) {
        println("Error: ${e.message}")
    }
}

Output:

Error: Something went wrong

Mistake 2: Using one generic exception type for every failure

fun processPayment(amount: Double) {
    if (amount <= 0) {
        throw Exception("Invalid amount: $amount")
    }
    if (amount > 10000) {
        throw Exception("Amount too large: $amount")
    }
    println("Processing $amount")
}

fun main() {
    try {
        processPayment(-5.0)
    } catch (e: Exception) {
        // The only way to tell these apart is by parsing e.message - fragile!
        println("Failed: ${e.message}")
    }
}

Throwing plain Exception for every distinct failure forces every caller to catch the same broad type and then parse the message text to figure out what actually went wrong. A typed hierarchy fixes this and lets callers catch selectively:

sealed class PaymentException(message: String) : Exception(message)
class InvalidAmountException(val amount: Double) : PaymentException("Invalid amount: $amount")
class AmountTooLargeException(val amount: Double) : PaymentException("Amount too large: $amount")

fun processPayment(amount: Double) {
    if (amount <= 0) throw InvalidAmountException(amount)
    if (amount > 10000) throw AmountTooLargeException(amount)
    println("Processing $amount")
}

fun main() {
    try {
        processPayment(-5.0)
    } catch (e: InvalidAmountException) {
        println("Please enter a positive amount (got ${e.amount})")
    } catch (e: AmountTooLargeException) {
        println("Maximum allowed is 10000 (got ${e.amount})")
    }
}

Output:

Please enter a positive amount (got -5.0)

Mistake 3: Forgetting that a sealed exception hierarchy makes when exhaustive

sealed class OrderException(message: String) : Exception(message)
class OrderNotFoundException(val id: Int) : OrderException("Order $id not found")
class OrderAlreadyShippedException(val id: Int) : OrderException("Order $id already shipped")

fun httpStatusFor(e: OrderException): Int = when (e) {
    is OrderNotFoundException -> 404
    // OrderAlreadyShippedException is not handled
}

Because OrderException is sealed, the compiler knows every possible subtype and rejects this code with “‘when’ expression must be exhaustive” — it will not compile until every branch is covered (or an else is added). This is a feature, not a bug: it turns a missed case into a compile-time error instead of a runtime surprise. The fix is to handle every subtype:

sealed class OrderException(message: String) : Exception(message)
class OrderNotFoundException(val id: Int) : OrderException("Order $id not found")
class OrderAlreadyShippedException(val id: Int) : OrderException("Order $id already shipped")

fun httpStatusFor(e: OrderException): Int = when (e) {
    is OrderNotFoundException -> 404
    is OrderAlreadyShippedException -> 409
}

fun main() {
    val errors = listOf(OrderNotFoundException(42), OrderAlreadyShippedException(7))
    for (err in errors) {
        println("${err.message} -> HTTP ${httpStatusFor(err)}")
    }
}

Output:

Order 42 not found -> HTTP 404
Order 7 already shipped -> HTTP 409

Best Practices

  • Name custom exceptions with an Exception suffix and a name that describes the failure, not the mechanism (InsufficientFundsException, not ValidationError1).
  • Extend the closest matching standard exception (IllegalArgumentException, IllegalStateException, NoSuchElementException) instead of always extending Exception directly, so existing generic catch blocks still work sensibly.
  • Always forward a descriptive message to the parent constructor; never leave it null.
  • When wrapping a lower-level exception, always pass it as cause so the original stack trace is not lost.
  • Group related failures under a single sealed class so a when over them is exhaustive and safe to extend later.
  • Add relevant data (an id, an offending value, an error code) as constructor properties instead of only embedding it in the message string, so callers can act on it programmatically.
  • Catch the most specific exception type you can actually handle; let everything else propagate rather than swallowing it with a broad catch (e: Exception).

Practice Exercises

  1. Define a sealed class ValidationException with two subtypes, BlankFieldException(val field: String) and TooLongException(val field: String, val max: Int). Write a function validate(field: String, value: String, max: Int) that throws the appropriate one, and a main that tries a few inputs and prints a friendly message for each.
  2. Create NegativeStockException(val product: String, val amount: Int) extending IllegalStateException. Write a function that decreases a warehouse item’s stock and throws this exception if the result would go below zero; catch it and print the product name and the amount that was missing.
  3. Write a function that parses a comma-separated string of numbers into a List<Int>, wrapping any NumberFormatException in a custom CsvParseException that preserves the original as its cause. Expected output for input "1,2,x,4" should mention both the parse failure and that the cause was a NumberFormatException.

Summary

  • A custom exception is a regular class that extends Exception or a more specific standard exception type.
  • Kotlin has no checked exceptions — there is no throws keyword, and the compiler never forces callers to catch anything.
  • Always forward message (and cause, when wrapping another exception) to the parent constructor so no diagnostic information is lost.
  • Add domain-specific properties with val constructor parameters so callers can act on structured data instead of parsing message strings.
  • Use sealed class for closed exception hierarchies so a when over the exception type is checked for exhaustiveness at compile time.
  • Catch the most specific exception type possible; avoid catching generic Exception unless you truly mean to handle everything.