1. Overview

Exception handling is an indispensable aspect of software development.

In this tutorial, we’ll delve into idiomatic approaches to conditional exception throwing in Kotlin, a modern and concise programming language.

2. Understanding Conditional Throwing

Conditional throwing refers to the practice of throwing an exception based on a certain condition:

if (condition) {
    throw SomeException(...)
}

Instead of using traditional if blocks to check conditions and throw exceptions, Kotlin allows us to express this logic more concisely, making our code more readable and reducing boilerplate.

In this tutorial, we’ll see various ways to handle conditional throwing in Kotlin.

3. Using the Standard require() and check() Functions

Kotlin provides the require(), requireNotNull(), check(), and checkNotNull() functions to perform conditional throwing.

If we want to throw an IllegalArgumentException, we can consider require() or requireNotNull() functions. 

Next, let’s see some examples of using these two functions:

val str = "a b c"
assertThrows<IllegalArgumentException> {
    require(str.length > 10) { "The string is too short." }
}

require() is a straightforward function. However, due to its return type of Unit, we cannot seamlessly execute additional operations after a require() check. To overcome this limitation, we can leverage a scope function, such as also():

val upperStr = str.also {
    require(it.split(" ").size == 3) { "Format not supported" }
}.uppercase()
 
assertEquals("A B C", upperStr)

In the above example, we fluently called uppercase() after the require() check.

requireNotNull(value) { optionalMessage } throws an IllegalArgumentException with the given message if value is null:

var nullableValue: String? = null
assertThrows<IllegalArgumentException> {
    requireNotNull(nullableValue) { "Null is not allowed" }
}

Unlike require(), requireNotNull() returns value. This allows us to fluently perform further operations after a requireNotNull() check:

nullableValue = "a b c"
val uppercaseValue = requireNotNull(nullableValue).uppercase()
assertEquals("A B C", uppercaseValue)

check() and checkNotNull() are pretty similar to require() and requireNotNull(). The only difference is check() and checkNotNull() throw IllegalStateException instead of IllegalArgumentException:

val str = "a b c"
assertThrows<IllegalStateException> {
    check(str.length > 10) { "The string is too short." }
}

var nullableValue: String? = null
assertThrows<IllegalStateException> {
    checkNotNull(nullableValue) { "Null is not allowed" }
}

nullableValue = "a b c"
val uppercaseValue = checkNotNull(nullableValue).uppercase()
assertEquals("A B C", uppercaseValue)

4. Using takeIf() ?: throw …

While the built-in functions like require() and check() offer convenience, they are limited to throwing IllegalArgumentException or IllegalStateException. But we often need to deal with different exceptions, for example, a customized exception type:

class MyException(msg: String) : Exception(msg)

One way to solve the problem is using takeIf() together with Kotlin’s Elvis operator (?:):

takeIf{ precondition lambda } ?: throw SomeException(...)

We rely on takeIf() to return null when the precondition isn’t met, which then triggers the Elvis operator argument.

Next, let’s see an example:

val str = "a b c"
assertThrows<MyException> {
    str.takeIf { it.length > 10 } ?: throw MyException("The string is too short.")
}

Apart from supporting all exception types, since takeIf() is an extension function, we can invoke it directly from the receiver object (str in this case) and easily reference the receiver object in takeIf()‘s lambda.

However, this approach has a limitation: It cannot handle scenarios where we consider null a valid value. This is because ?: throw …” will be triggered when receiverObj.takeIf(precondition) returns null, and there are two cases where receiverObj.takeIf(precondition) return null:

  • The precondition function returns false
  • The precondition function returns true, but receiverObj is null

An example can show this scenario clearly:

val nullIsValid: String? = null
assertThrows<MyException> {
    nullIsValid.takeIf { true } ?: throw MyException("The string is too short.")
}

Therefore, we shouldn’t use this approach if null is a valid case.

5. The throwIf() Function

Another idea to perform conditional throwing is creating a function wrapping the “if (…) throw …” block, to make it easier to use:

inline fun throwIf(throwCondition: Boolean, exProvider: () -> Exception) {
    if (throwCondition) throw exProvider()
}

As the code above shows, the throwIf() function has two arguments. The first one is the condition in Boolean, and the other one is a function that provides an Exception instance.

Next, let’s see how to use throwIf() through an example:

val str = "a b c"
assertThrows<MyException> {
    throwIf(str.length <= 10) { MyException("The string is too short.") }
}

As we can see, throwIf() improved the readability of conditional throwing. It’s worth mentioning that, as the exProvider argument is a function, {MyException(“…”)} executes only if throwCondition is true. In essence, exProvider supplies the Exception instance lazily, thereby avoiding creating unnecessary objects.

Since throwIf() returns Unit, if we would like to seamlessly perform further operations after throwIf(), we can use the same trick by leveraging the also() function:

val uppercaseValue = str.also {
    throwIf(it.split(" ").size != 3) { MyException("Format not supported") }
}.uppercase()
assertEquals("A B C", uppercaseValue)

6. The mustHave() Function

So far, we’ve seen different approaches to performing idiomatic conditional throwing in Kotlin. They’re likely adequate for most of our everyday tasks. However, if we consider them carefully, they have various disadvantages:

  • Exception type limitations – check()/require()
  • Not a functional/fluent API – check()/require() and throwIf()
  • null handling limitations – takeIf()

So, next, let’s summarize the ideal features that we want a conditional throwing function to have:

  • Support all exception types
  • An extension function – Enabling any object to be the receiver and invoke it directly
  • Return the receiver object if it doesn’t throw an exception – allowing for fluent chaining of operations
  • Exception provider as a function – facilitating support for sophisticated error handling logic and lazy object instantiation
  • Condition as a function – accommodating more complex checks
  • The receiver as the parameter in both exception provider and condition functions – simplifying referencing the receiver in those functions

Next, let’s try to create a function to fulfill our needs:

inline fun <T : Any?> T.mustHave(
    otherwiseThrow: (T) -> Exception = { IllegalStateException("mustHave check failed: $it") },
    require: (T) -> Boolean
): T {
    if (!require(this)) throw otherwiseThrow(this)
    return this
}

The mustHave() function accepts two parameters, both of which are functions. otherwiseThrow has a default value, a function that returns an IllegalStateException object. That is to say, if no specific exception provider is specified, an IllegalStateException will be thrown if require isn’t satisfied.

Next, let’s see how to use mustHave() through examples. Let’s say we have a simple data class Player, and the InvalidPlayerException class:

data class Player(val id: Int, val name: String, val score: Int)
class InvalidPlayerException(message: String) : RuntimeException(message)

Now, we want to throw the exception if a player’s score is negative:

val kai = Player(1, "Kai", -5)
assertThrows<IllegalStateException> { kai.mustHave { it.score >= 0 } }
    .also { ex -> assertEquals("mustHave check failed: Player(id=1, name=Kai, score=-5)", ex.message) }

As we can see, we provide the lambda to check if the player is valid. In this example, we simply check the score value. If it’s required, the lambda can contain sophisticated implementations.

Also, since we didn’t specify the otherwiseThrow parameter, an IllegalStateException was thrown as default. Of course, we can pass a function to ask mustHave() to throw the specified exception:

assertThrows<InvalidPlayerException> {
    kai.mustHave(
        require = { it.score >= 0 },
        otherwiseThrow = { InvalidPlayerException("Player [id=${it.id}, name=${it.name}] is invalid") }
    )
}.also { ex -> assertEquals("Player [id=1, name=Kai] is invalid", ex.message) }

We used named parameters to make the code easy to read. Additionally, it’s worth mentioning that, since the receiver is the parameter for both require and otherwiseThrow functions, we can easily reference the receiver in the lambdas. For instance, we can use the implicit variable “it” in both lambda expressions.

Finally, if the receiver object passes the require check, mustHave() allows us to chain further operations to process the receiver seamlessly:

val liam = Player(2, "Liam", 42)
val upperDescription = liam.mustHave(
    require = { it.score >= 0 },
    otherwiseThrow = { InvalidPlayerException("Player [id=${it.id}, name=${it.name}] is invalid") }
).let { "${it.name} : ${it.score}".uppercase() }

assertEquals("LIAM : 42", upperDescription)

7. Conclusion

In this article, we aimed to discover concise and idiomatic approaches to handling conditional exception throwing in Kotlin. We explored the built-in options and several custom functions, such as throwIf() and mustHave().

As always, the complete source code for the examples is available over on GitHub.

Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments