PCSalt
YouTube GitHub
Back to Kotlin
Kotlin · 3 min read

Kotlin + Arrow — Functional Error Handling (Either, Raise)

Learn functional error handling in Kotlin with Arrow — Either, Raise, typed errors, and how to replace try-catch with composable error handling.


try-catch works. But it has problems. Exceptions are invisible in function signatures — you can’t tell from the type that getUser(id) might fail. The compiler doesn’t help. You either catch everything or miss something.

Arrow is a Kotlin library for functional programming. Its error handling — Either and Raise — makes errors explicit, composable, and compiler-checked. You see errors in the type signature, handle them with pattern matching, and compose operations that might fail without nesting try-catch blocks.

The problem with exceptions

fun getUser(id: String): User {
    val response = api.getUser(id) // might throw IOException
    return response.body() ?: throw NotFoundException("User not found")
}

fun getUserProfile(id: String): Profile {
    val user = getUser(id) // caller doesn't know this throws
    val preferences = getPreferences(id) // this also throws
    return Profile(user, preferences)
}

Nothing in the type signature tells you getUser can fail. You have to read the implementation, check documentation, or discover at runtime. In Java, checked exceptions tried to solve this, but developers hated them and wrapped everything in RuntimeException.

Either — Errors in the type

Either<E, A> represents a value that is either a Left(error) or a Right(success):

import arrow.core.Either
import arrow.core.left
import arrow.core.right

sealed class UserError {
    data class NotFound(val id: String) : UserError()
    data class NetworkError(val message: String) : UserError()
}

fun getUser(id: String): Either<UserError, User> {
    return try {
        val response = api.getUser(id)
        if (response.isSuccessful) {
            response.body()?.right() ?: UserError.NotFound(id).left()
        } else {
            UserError.NotFound(id).left()
        }
    } catch (e: IOException) {
        UserError.NetworkError(e.message ?: "Network error").left()
    }
}

Now the return type tells you: “This returns a User or a UserError.” The compiler forces you to handle both.

Handling Either

fun displayUser(id: String) {
    when (val result = getUser(id)) {
        is Either.Left -> when (result.value) {
            is UserError.NotFound -> showNotFound(result.value.id)
            is UserError.NetworkError -> showNetworkError(result.value.message)
        }
        is Either.Right -> showUser(result.value)
    }
}

Or use fold:

getUser("123").fold(
    ifLeft = { error -> handleError(error) },
    ifRight = { user -> showUser(user) }
)

Chaining with map and flatMap

The real power shows when you chain operations:

fun getUserProfile(id: String): Either<UserError, Profile> {
    return getUser(id).flatMap { user ->
        getPreferences(id).map { preferences ->
            Profile(user, preferences)
        }
    }
}

If getUser fails, flatMap short-circuits — getPreferences never runs. If getUser succeeds, getPreferences runs. If both succeed, you get a Profile. Error handling is automatic.

Compare with try-catch:

fun getUserProfile(id: String): Profile {
    return try {
        val user = getUser(id) // might throw
        val preferences = getPreferences(id) // might throw
        Profile(user, preferences)
    } catch (e: NotFoundException) {
        // handle
    } catch (e: IOException) {
        // handle
    }
}

The Either version is more explicit about what can fail and composes without nesting.

Raise — Arrow’s modern approach

flatMap chaining can get verbose. Arrow’s Raise context provides a cleaner syntax:

import arrow.core.raise.either
import arrow.core.raise.raise

fun getUser(id: String): Either<UserError, User> = either {
    val response = try {
        api.getUser(id)
    } catch (e: IOException) {
        raise(UserError.NetworkError(e.message ?: "Network error"))
    }

    response.body() ?: raise(UserError.NotFound(id))
}

Inside either { }, you can call raise() to short-circuit with an error. It looks like throwing an exception, but it’s typed — the return type is Either<UserError, User>.

Chaining with Raise

fun getUserProfile(id: String): Either<UserError, Profile> = either {
    val user = getUser(id).bind() // unwraps Either, raises on Left
    val preferences = getPreferences(id).bind()
    Profile(user, preferences)
}

bind() unwraps an Either. If it’s a Right, you get the value. If it’s a Left, it short-circuits the whole either { } block. No nesting, no flatMap chains — just sequential code that handles errors automatically.

Calling raise directly

fun validateAge(age: Int): Either<String, Int> = either {
    ensure(age >= 0) { "Age cannot be negative" }
    ensure(age <= 150) { "Age is unrealistic" }
    age
}

ensure is like require, but it raises a typed error instead of throwing an exception.

Typed error hierarchies

Use sealed classes for structured error types:

sealed class AppError {
    sealed class Auth : AppError() {
        data object InvalidCredentials : Auth()
        data object TokenExpired : Auth()
        data object Forbidden : Auth()
    }

    sealed class Data : AppError() {
        data class NotFound(val resource: String, val id: String) : Data()
        data class Conflict(val message: String) : Data()
    }

    sealed class Network : AppError() {
        data class Timeout(val url: String) : Network()
        data class ConnectionFailed(val message: String) : Network()
    }
}

Functions declare which errors they can produce:

fun login(email: String, password: String): Either<AppError.Auth, Session> = either {
    val user = userRepository.findByEmail(email)
        ?: raise(AppError.Auth.InvalidCredentials)

    if (!passwordEncoder.matches(password, user.passwordHash)) {
        raise(AppError.Auth.InvalidCredentials)
    }

    Session(userId = user.id, token = generateToken(user))
}

The type says Either<AppError.Auth, Session> — this function can only fail with auth errors, not network errors or data errors. Callers know exactly what to expect.

Accumulating errors

Sometimes you want all errors, not just the first one. Arrow supports this:

import arrow.core.raise.either
import arrow.core.raise.zipOrAccumulate
import arrow.core.NonEmptyList

data class ValidationError(val field: String, val message: String)

fun validateUser(
    name: String,
    email: String,
    age: Int
): Either<NonEmptyList<ValidationError>, ValidUser> = either {
    zipOrAccumulate(
        { ensure(name.isNotBlank()) { ValidationError("name", "Name is required") } },
        { ensure(email.contains("@")) { ValidationError("email", "Invalid email") } },
        { ensure(age in 0..150) { ValidationError("age", "Invalid age") } }
    ) { _, _, _ ->
        ValidUser(name, email, age)
    }
}

zipOrAccumulate runs all validations and collects all errors, not just the first one. The result is Either<NonEmptyList<ValidationError>, ValidUser>.

val result = validateUser("", "bad-email", -5)
// Left(NonEmptyList(
//   ValidationError("name", "Name is required"),
//   ValidationError("email", "Invalid email"),
//   ValidationError("age", "Invalid age")
// ))

This is great for form validation — show all errors at once instead of one at a time.

Integrating with existing code

Wrapping try-catch

import arrow.core.raise.catch

fun readFile(path: String): Either<AppError, String> = either {
    catch({ File(path).readText() }) { e: IOException ->
        raise(AppError.Network.ConnectionFailed(e.message ?: "File read failed"))
    }
}

Converting to/from nullable

import arrow.core.toEither

// Nullable to Either
val user: User? = findUser("123")
val result: Either<String, User> = user.toEither { "User not found" }

// Either to nullable
val maybeUser: User? = getUser("123").getOrNull()

Working with suspend functions

either { } works with suspend functions:

suspend fun fetchDashboard(userId: String): Either<AppError, Dashboard> = either {
    val user = userService.getUser(userId).bind()
    val stats = statsService.getStats(userId).bind()
    val notifications = notificationService.getUnread(userId).bind()
    Dashboard(user, stats, notifications)
}

Each .bind() call is a potential short-circuit. If any service returns a Left, the rest don’t execute.

Either vs Result vs Exceptions

ApproachType-safeComposableAccumulationEcosystem
ExceptionsNoNoNoBuilt-in
kotlin.ResultPartial (uses Throwable)LimitedNoBuilt-in
Arrow EitherYes (typed errors)YesYesArrow library

kotlin.Result is simpler but only works with Throwable. You can’t define typed error hierarchies. Arrow Either gives you full control over error types.

For simple cases, Result or even exceptions are fine. When you need typed errors, composition, or error accumulation, Arrow is the better tool.

When to use Arrow error handling

Good fit:

  • Complex business logic with multiple failure modes
  • Validation that needs to report all errors
  • API layers where callers need to know specific error types
  • Shared code (KMP) where platform exceptions differ

Overkill:

  • Simple CRUD with try-catch
  • Prototypes where speed matters more than safety
  • Teams unfamiliar with functional patterns
  • Code where exceptions are truly exceptional (not control flow)

Summary

Arrow’s error handling makes failures explicit in the type system:

  • Either<E, A> — a value that’s either an error or a success
  • raise() — short-circuit with a typed error inside either { }
  • bind() — unwrap an Either, short-circuit on error
  • ensure() — validate a condition, raise on failure
  • zipOrAccumulate() — collect all errors, not just the first

The result: error handling that the compiler checks, that composes cleanly, and that your IDE autocompletes. It’s more code than a bare try-catch, but it prevents the bugs that bare try-catch lets through.