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
| Approach | Type-safe | Composable | Accumulation | Ecosystem |
|---|---|---|---|---|
| Exceptions | No | No | No | Built-in |
kotlin.Result | Partial (uses Throwable) | Limited | No | Built-in |
Arrow Either | Yes (typed errors) | Yes | Yes | Arrow 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 successraise()— short-circuit with a typed error insideeither { }bind()— unwrap an Either, short-circuit on errorensure()— validate a condition, raise on failurezipOrAccumulate()— 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.