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) }runCatchingcatchesThrowable, including cancellation and fatal errors. Re-throwCancellationExceptionwhen wrapping suspending code.Resultcarries no information about which failure occurred, so callers cannot handle cases differently.- Prefer
requirefor invalid arguments andcheckfor invalid state; both throwIllegalArgumentExceptionandIllegalStateExceptionwith 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>
}| Situation | Use | Why |
|---|---|---|
| Invalid argument | require | Programmer error, fail fast |
| Invalid state | check | Programmer error, fail fast |
| Expected failure | Typed result | Caller must handle it |
| Unexpected failure | Exception | Bubbles to a boundary handler |
| Cancellation | Rethrow | Never 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.
Related
JSON, serialization and HTTP clients Channels, shared state and advanced concurrency
Last refreshed 2026-09-18.