Feb 17, 2025 - ⧖ 10 min

Introduction: Why Rethink Exception Handling?

If you’ve been working with Kotlin or Java for a while, you’re probably used to handling errors with try-catch blocks. It’s the standard way, and it works fine for most standard porpuse applications... But, on complex scenarios just adding more and more of those blocks can be often messy, verbose, and forces you to constantly worry about where exceptions might be thrown. What if there were a better way? A way to handle errors explicitly, without unexpected surprises?

That’s where functional programming concepts come in. While I wouldn’t call myself a functional programming purist(I'm mostly scared of haskell nerds), I’ve learned over time that some of those "academic" ideas can actually make our code cleaner, more robust, and easier to reason about.

One such concept is the monad, which, provides a powerful way to handle side effects, errors, and sequencing. Kotlin, even though it’s not a purely functional language, provides tools that let us embrace this style and libraries like kotlin-result make it even simpler.

In this post, I’ll try to break down why traditional exception handling falls short on complex scenarios, how monads work, and how you can use them to write cleaner, organized and safer Kotlin code.

The Problem: Traditional Exception Handling

Traditional exception handling in Java and Kotlin relies heavily on try-catch blocks. While this approach works, it comes with some well-known problems:

1. Hidden Control Flow

Traditional exceptions create an invisible "goto" in your code. When you see:

fun processUser(user: User) {
    validateUser(user)
    updateProfile(user)
    notifyUser(user)
}

You might think this code runs sequentially, but any of these methods could throw an exception, creating hidden control flow paths. As Raymond Chen from Microsoft noted, "Exceptions are like non-local goto statements." This makes it:

  • Hard to reason about the code's execution path
  • Difficult to ensure all error cases are handled
  • Easy to miss cleanup code in some error paths

Wouldn’t it be better in this case if we could treat errors as values, passing them around like any other data? That’s exactly what the functional approach enables.

2. The Checked vs. Unchecked Dilemma

Java tried to solve this with checked exceptions, but that created its own set of problems:

try {
    fileOperation()  // throws IOException
    networkCall()    // throws NetworkException
    dbOperation()    // throws SQLException
} catch (IOException e) {
    // Handle file error
} catch (NetworkException e) {
    // Handle network error
} catch (SQLException e) {
    // Handle DB error
}

This leads to:

  • Exception proliferation through the codebase
  • Forced handling of exceptions at inappropriate levels
  • Violation of the Open/Closed Principle when adding new error cases
  • "Exception tunneling" where developers wrap exceptions just to satisfy the compiler, possibly the ugliest thing we see out there.

3. Resource Management Complexity

Consider this seemingly simple operation:

fun processOrders() {
    val connection = dataSource.connection
    try {
        val statement = connection.createStatement()
        try {
            val result = statement.executeQuery("SELECT * FROM orders")
            try {
                // Process result
            } finally {
                result.close()
            }
        } finally {
            statement.close()
        }
    } finally {
        connection.close()
    }
}

Problems here include:

  • Nested try-finally blocks create unreadable code
  • Easy to forget resource cleanup in some error paths
  • Difficult to maintain proper cleanup order
  • Hard to ensure all resources are properly disposed

4. Loss of Type Safety

Exceptions break type safety by introducing failure modes that aren't represented in function signatures:

// What can go wrong here? The signature doesn't tell us
fun getUserProfile(id: String): UserProfile {
    // Could throw:
    // - DatabaseException
    // - ValidationException
    // - SerializationException
    // - NetworkException
    // ... and we wouldn't know from the signature
}

This leads to:

  • Runtime surprises
  • Difficulty in API contract documentation
  • Impossible to ensure all error cases are handled at compile time
  • Breaking of referential transparency

Enter the Functional World: How Monads Solve These Problems

Before we dive into the solution, let's understand how functional programming concepts can address each of these issues. No catehory theory diagrams or greek symbols will be used here.

Functors, Applicatives, and Monads

Functors: Safe Value Transformations

A functor is any type that can be mapped over while preserving its structure. In Kotlin, the simplest example is List:

val numbers = listOf(1, 2, 3)
val doubled = numbers.map { it * 2 } // [2, 4, 6]

A monad extends this concept by allowing us to chain operations while maintaining context (like success/failure states). This is exactly what we need for proper error handling!

The Solution: kotlin-result in Action

The kotlin-result library brings these functional programming concepts to Kotlin with zero runtime overhead. Let's see how it addresses each of our problems:

1. Explicit Control Flow

Instead of hidden exception paths, we make failures explicit:

fun processUser(user: User): Result<ProcessedUser, UserError> =
    validateUser(user)
        .andThen { validUser -> 
            updateProfile(validUser)
        }
        .andThen { updatedUser ->
            notifyUser(updatedUser)
        }

Now we can:

  • See exactly where things might fail
  • Handle each error case explicitly
  • Ensure proper resource cleanup

2. Type-Safe Error Handling

Instead of catching random exceptions, we model our errors as types:

sealed class UserError {
    data class ValidationError(val reason: String) : UserError()
    data class DatabaseError(val message: String) : UserError()
    data class NotificationError(val message: String) : UserError()
}

fun validateUser(user: User): Result<ValidUser, UserError.ValidationError>
fun updateProfile(user: ValidUser): Result<UpdatedUser, UserError.DatabaseError>
fun notifyUser(user: UpdatedUser): Result<ProcessedUser, UserError.NotificationError>

Benefits:

  • Error types are part of the function signature
  • Compiler ensures we handle all error cases
  • No more exception tunneling
  • Clear API contracts

3. Clean Resource Management

The Result type works beautifully with Kotlin's scope functions:

fun processOrders(): Result<List<Order>, DBError> =
    dataSource.connection.use { connection ->
        connection.createStatement().use { statement ->
            statement.executeQuery("SELECT * FROM orders").use { result ->
                runCatching { 
                    result.toOrders()
                }.mapError { 
                    DBError.QueryFailed(it.message)
                }
            }
        }
    }

4. Composable Error Handling

For complex operations with multiple failure points, we can use monad comprehensions:

val result: Result<ProcessedOrder, OrderError> = binding {
    val user = findUser(userId).bind()
    val order = validateOrder(orderData).bind()
    val payment = processPayment(order.total).bind()
    val savedOrder = saveOrder(order, payment).bind()

    // Notification failure doesn't stop the flow
    notifyUser(savedOrder.id).onFailure { error ->
        logger.warn("Failed to notify user: $error")
    }

    savedOrder
}

Benefits:

  • Linear code flow
  • Early return on first error
  • Clear error propagation
  • Easy to test each step independently

5. Railway-Oriented Programming

The Result type implements what's known as Railway-Oriented Programming, where success and failure are like two parallel tracks:

fun validateUserRegistration(input: RegistrationInput): Result<ValidUser, ValidationError> =
    validateEmail(input.email)
        .andThen { email ->
            validatePassword(input.password)
                .map { password -> email to password }
        }
        .andThen { (email, password) ->
            validateAge(input.age)
                .map { age -> ValidUser(email, password, age) }
        }

This gives us:

  • Clear separation of happy and unhappy paths
  • Easy to add new validation steps
  • Composable error handling
  • Type-safe error accumulation

Making Life Even Better: Advanced Patterns

Pattern 1: Parallel Validations

suspend fun validateUserProfile(profile: UserProfile): Result<ValidProfile, ValidationError> =
    coroutineBinding {
        val email = async { validateEmail(profile.email).bind() }
        val phone = async { validatePhone(profile.phone).bind() }
        val address = async { validateAddress(profile.address).bind() }

        ValidProfile(
            email = email.await(),
            phone = phone.await(),
            address = address.await()
        )
    }

Pattern 2: Domain Error Handling

sealed class DomainError {
    sealed class Validation : DomainError() {
        data class InvalidEmail(val email: String) : Validation()
        data class InvalidPassword(val reason: String) : Validation()
        data class InvalidAge(val age: Int) : Validation()
    }

    sealed class Infrastructure : DomainError() {
        data class DatabaseError(val message: String) : Infrastructure()
        data class NetworkError(val message: String) : Infrastructure()
    }
}

// Easy mapping to HTTP responses
fun DomainError.toHttpResponse(): ResponseEntity<ErrorResponse> =
    when (this) {
        is DomainError.Validation -> ResponseEntity.badRequest()
            .body(ErrorResponse(this.toString()))
        is DomainError.Infrastructure -> ResponseEntity.internalServerError()
            .body(ErrorResponse("An internal error occurred"))
    }

Conclusion: Better Exception Handling Through Functional Programming

Functional programming concepts like monads aren't just academic exercises—they provide practical solutions to real problems in exception handling:

✅ Explicit error paths instead of hidden control flow ✅ Type-safe error handling ✅ Clean resource management ✅ Composable error handling ✅ Easy testing ✅ Zero runtime overhead

So next time you encounter a Haskell enthusiast excited about monads, maybe don't run away immediately. They might actually be onto something useful—just don't tell them I said that, or they'll start talking about applicative functors, and nobody wants that. 😉

Remember: good error handling isn't about catching exceptions—it's about making failure impossible to ignore and easy to handle. Functional programming gives us the tools to do exactly that.


🔧 Ready to upgrade your error handling? Give kotlin-result a try and let me know in the comments how it transformed your code!

Feb 17, 2025 - ⧖ 10 min