Exceptions, Result and error-handling patterns

try as an expression, runCatching and Result, sealed error hierarchies, require and check, and validating at the boundaries instead of everywhere.

try, runCatching and Result

// try is an expression, so it can initialise a value
val port: Int = try {
    System.getenv("PORT").toInt()
} catch (_: NumberFormatException) {
    8080
}

// runCatching turns a throwing call into a Result
fun parseAmount(raw: String): Result<Long> = runCatching {
    require(raw.isNotBlank()) { "amount must not be blank" }
    val value = raw.removePrefix("GBP").trim().toLong()
    require(value >= 0) { "amount must not be negative" }
    value
}

val message = parseAmount("GBP 1,250")
    .map { "ok: " + it }
    .recoverCatching { "failed: " + it.message }
    .getOrThrow()

// Result is not for everything: it allocates and it hides the type of failure
parseAmount("abc")
    .onSuccess { println("parsed " + it) }
    .onFailure { println("rejected: " + it.message) }
  • runCatching catches Throwable, including cancellation and fatal errors. Re-throw CancellationException when wrapping suspending code.
  • Result carries no information about which failure occurred, so callers cannot handle cases differently.
  • Prefer require for invalid arguments and check for invalid state; both throw IllegalArgumentException and IllegalStateException with a clear message.
  • Do not swallow a failure just to keep a function signature simple — surface it or convert it into a typed error.

A sealed error type

sealed interface LoadError {
    data class Network(val cause: Throwable) : LoadError
    data class Http(val status: Int, val body: String?) : LoadError
    data object NotFound : LoadError
    data class Decoding(val path: String) : LoadError
}

suspend fun loadUser(id: String): Either<User> =
    try {
        val response = client.get("/users/" + id)
        when (response.status.value) {
            in 200..299 -> Either.Ok(json.decodeFromString<User>(response.body()))
            404 -> Either.Err(LoadError.NotFound)
            else -> Either.Err(LoadError.Http(response.status.value, response.body()))
        }
    } catch (e: CancellationException) {
        throw e
    } catch (e: IOException) {
        Either.Err(LoadError.Network(e))
    } catch (e: SerializationException) {
        Either.Err(LoadError.Decoding("/user"))
    }

sealed interface Either<out T> {
    data class Ok<T>(val value: T) : Either<T>
    data class Err(val error: LoadError) : Either<Nothing>
}
SituationUseWhy
Invalid argumentrequireProgrammer error, fail fast
Invalid statecheckProgrammer error, fail fast
Expected failureTyped resultCaller must handle it
Unexpected failureExceptionBubbles to a boundary handler
CancellationRethrowNever treat it as an error

Checking at the boundary

// one place converts untrusted input into a trusted type
@JvmInline
value class Email private constructor(val value: String) {
    companion object {
        fun parse(raw: String): Email? {
            val trimmed = raw.trim().lowercase()
            return if (trimmed.length in 3..254 && "@" in trimmed && !trimmed.startsWith("@")) {
                Email(trimmed)
            } else {
                null
            }
        }
    }
}

fun register(rawEmail: String): Registration {
    val email = Email.parse(rawEmail) ?: return Registration.Invalid("email")
    return Registration.Accepted(email)
}
💡
Validate where data enters the system — a request body, a form, a configuration file — and let the rest of the code assume valid types. Scattering null checks through every layer means no layer can be trusted.

FAQ

Should I use <code>Result</code> or a sealed class?
Use a sealed hierarchy for anything a caller must distinguish: not found, unauthorised, conflict and network failure all lead to different behaviour. Result is fine for a quick internal helper where the only question is success or failure.
Why is cancellation treated specially?
Coroutine cancellation is implemented as an exception. Catching it and converting it into a normal error breaks structured concurrency and leaves work running that should have stopped.

JSON, serialization and HTTP clients Channels, shared state and advanced concurrency

Last refreshed 2026-09-18.