Introduction

Most Kotlin codebases I’ve worked on end up with a mix of try/catch blocks, nullable returns, and custom “Either” types. The result is inconsistent error handling that makes callers guess whether a function throws, returns null, or wraps the problem in a wrapper class. Over the last couple of years I’ve settled on a pattern that uses the standard library’s Result<T> together with a small sealed‑class hierarchy for domain errors. It gives you compile‑time exhaustiveness, readable call sites, and zero runtime overhead.

The Problem with Exceptions

Checked exceptions don’t exist in Kotlin, so the compiler can’t force you to handle them. Unchecked exceptions bubble up silently until they crash a coroutine or an HTTP handler. Returning T? loses the reason for failure, and a generic Either<E, T> forces you to define a new type for every module. What we really want is a single, well‑known container that says “this call either succeeded with a value or failed with a *known* error”.

Introducing Result<T>

Result has been in the stdlib since 1.3. It’s essentially a discriminated union with two states: Success(value) and Failure(exception). The API is tiny but powerful:

inline fun  Result.fold(onSuccess: (T) -> R, onFailure: (Throwable) -> R): R
    = when (this) {
        is Result.Success -> onSuccess(value)
        is Result.Failure -> onFailure(exception)
    }

Because it’s an inline function, the compiler erases the lambda overhead. You can also use getOrElse, map, recover, and onFailure for fluent chaining.

Sealed Classes for Domain Errors

Exceptions are too generic for business logic. A network timeout, a validation rule violation, and a “not found” condition are semantically different and often require distinct UI handling. A sealed hierarchy gives you a closed set of error types that the compiler can exhaustively match.

sealed interface RepoError {
    data class NotFound(val id: String) : RepoError
    data class ValidationFailed(val messages: List) : RepoError
    object NetworkUnavailable : RepoError
    data class Unknown(val cause: Throwable) : RepoError
}

Notice the Unknown wrapper – it lets you keep the original exception for logging while still staying inside the sealed hierarchy.

Putting It Together: A Repository Example

Here’s a tiny user repository that talks to a REST endpoint. The public API returns Result<User, RepoError> (a type alias for readability).

typealias RepoResult = Result

class UserRepository(private val client: HttpClient) {
    suspend fun fetchUser(id: String): RepoResult = runCatching {
        val response = client.get("/users/$id")
        when (response.status) {
            200 -> response.bodyAs()
            404 -> throw RepoError.NotFound(id)
            400 -> throw RepoError.ValidationFailed(response.bodyAs>())
            else -> throw RepoError.Unknown(HttpResponseException(response.status))
        }
    }.mapFailure { throwable ->
        when (throwable) {
            is RepoError -> throwable
            is IOException -> RepoError.NetworkUnavailable
            else -> RepoError.Unknown(throwable)
        }
    }
}

Why this works: runCatching wraps the whole block in a Result. The mapFailure step normalises any thrown exception into our sealed hierarchy. Callers get a single Result they can fold or onSuccess/onFailure without ever seeing a raw exception.

Testing and Ergonomics

Unit tests become trivial because you can assert on the exact error variant:

@Test
fun `fetchUser returns NotFound for missing id`() = runTest {
    val repo = UserRepository(mockClient)
    whenever(mockClient.get("/users/404")).thenReturn(Response(404, ""))
    val result = repo.fetchUser("404")
    assertTrue(result.isFailure)
    assertEquals(RepoError.NotFound("404"), result.exceptionOrNull())
}

Because Result is a value type, you can also use it in Flow or Sequence pipelines without boxing overhead.

When to Use This Pattern

  • Public API boundaries – controllers, repository interfaces, gRPC adapters.
  • Business‑logic layers – where you need to distinguish validation, not‑found, and infrastructure failures.
  • Library code – consumers get a stable, documented error contract.

Avoid it for tiny internal helpers where a simple null or Boolean is clearer. Also, if you already have a mature Either implementation that the team loves, stick with it – consistency beats perfection.

Tip: Define a small extension fun Result.toRepoResult(): RepoResult that maps Failure to RepoError.Unknown. It lets you gradually migrate legacy code without a big bang rewrite.

Conclusion

Combining Result with a sealed‑class error hierarchy gives you the best of both worlds: the ergonomics of a monadic container and the type safety of a closed error set. It eliminates surprise exceptions, makes call‑site handling explicit, and keeps the generated bytecode lean. Next time you reach for a try/catch or a nullable return, ask yourself whether a Result<T, MyError> would make the contract clearer – most of the time, it will.