kotlinadvanced

Functional Error Handling with Either

Use Either and Option for type-safe error handling: Railway-oriented programming without exceptions.

kotlin
// Minimal Either implementation (or use Arrow library)
sealed class Either<out L, out R> {
    data class Left<out L>(val value: L) : Either<L, Nothing>()
    data class Right<out R>(val value: R) : Either<Nothing, R>()

    fun <T> fold(onLeft: (L) -> T, onRight: (R) -> T): T = when (this) {
        is Left -> onLeft(value)
        is Right -> onRight(value)
    }

    fun <T> map(f: (R) -> T): Either<L, T> = when (this) {
        is Left -> this
        is Right -> Right(f(value))
    }

    fun <T> flatMap(f: (R) -> Either<L, T>): Either<L, T> = when (this) {
        is Left -> this
        is Right -> f(value)
    }

    fun getOrElse(default: () -> @UnsafeVariance R): R = when (this) {
        is Left -> default()
        is Right -> value
    }

    val isRight get() = this is Right
    val isLeft get() = this is Left
}

fun <L, R> Either<L, R>.orElse(alt: () -> Either<L, R>): Either<L, R> = when (this) {
    is Either.Left -> alt()
    is Either.Right -> this
}

// Domain errors
sealed class AppError(val message: String) {
    class NotFound(entity: String, id: String) : AppError("$entity '$id' not found")
    class Validation(msg: String) : AppError(msg)
    class Unauthorized(msg: String = "Not authorized") : AppError(msg)
    class Database(msg: String) : AppError("DB error: $msg")
}

// Domain
data class Email private constructor(val value: String) {
    companion object {
        fun create(raw: String): Either<AppError, Email> {
            if (raw.isBlank()) return Either.Left(AppError.Validation("Email required"))
            if (!raw.contains("@")) return Either.Left(AppError.Validation("Invalid email"))
            if (raw.length > 255) return Either.Left(AppError.Validation("Email too long"))
            return Either.Right(Email(raw.lowercase().trim()))
        }
    }
}

data class User(val id: String, val name: String, val email: Email)

// Repository
class UserRepo {
    private val users = mutableMapOf<String, User>()

    fun save(user: User): Either<AppError, User> = try {
        users[user.id] = user
        Either.Right(user)
    } catch (e: Exception) {
        Either.Left(AppError.Database(e.message ?: "Unknown"))
    }

    fun findById(id: String): Either<AppError, User> =
        users[id]?.let { Either.Right(it) }
            ?: Either.Left(AppError.NotFound("User", id))
}

// Service — railway-oriented
class UserService(private val repo: UserRepo) {
    fun createUser(id: String, name: String, rawEmail: String): Either<AppError, User> {
        if (name.isBlank()) return Either.Left(AppError.Validation("Name required"))
        if (name.length > 50) return Either.Left(AppError.Validation("Name too long"))

        return Email.create(rawEmail)
            .map { email -> User(id, name.trim(), email) }
            .flatMap { user -> repo.save(user) }
    }

    fun getUser(id: String): Either<AppError, User> = repo.findById(id)

    fun getUserEmail(id: String): Either<AppError, String> =
        repo.findById(id).map { it.email.value }
}

// Combine multiple Either values
fun <L, A, B, C> zip(
    a: Either<L, A>,
    b: Either<L, B>,
    f: (A, B) -> C
): Either<L, C> = a.flatMap { av -> b.map { bv -> f(av, bv) } }

fun main() {
    val repo = UserRepo()
    val service = UserService(repo)

    // Success path
    val result1 = service.createUser("1", "Alice", "alice@test.com")
    result1.fold(
        onLeft = { println("Error: ${it.message}") },
        onRight = { println("Created: $it") }
    )

    // Validation error
    val result2 = service.createUser("2", "", "bob@test.com")
    result2.fold(
        onLeft = { println("Error: ${it.message}") },
        onRight = { println("Created: $it") }
    )

    // Invalid email
    val result3 = service.createUser("3", "Charlie", "not-email")
    println("Result3: ${result3.fold({ it.message }, { it.name })}")

    // Chain operations
    val email = service.getUserEmail("1")
    println("Email: ${email.fold({ it.message }, { it })}")

    // Not found
    val missing = service.getUser("999")
    println("Missing: ${missing.fold({ it.message }, { it })}")

    // getOrElse
    val userName = service.getUser("999")
        .map { it.name }
        .getOrElse { "Unknown" }
    println("Name: $userName")

    // Pattern matching with when
    when (val r = service.getUser("1")) {
        is Either.Left -> when (r.value) {
            is AppError.NotFound -> println("Not found")
            is AppError.Validation -> println("Invalid")
            is AppError.Unauthorized -> println("Unauthorized")
            is AppError.Database -> println("DB Error")
        }
        is Either.Right -> println("Found: ${r.value.name}")
    }
}

Use Cases

  • Type-safe error handling without exceptions
  • Railway-oriented business logic
  • Composable validation pipelines

Tags

Related Snippets

Similar patterns you can reuse in the same workflow.