Skip to content
All posts
August 26, 20267 min read

Kotlin Result Types: Error Handling Without Exceptions

This post shows how to replace exception-based error handling with Kotlin's Result type and sealed class hierarchies, including composition patterns and repository integration.

AndroidKotlinArchitectureBest Practices
Share:

Exceptions are the lazy way to handle errors. They hide failure paths in stack traces, force try-catch everywhere, and make control flow invisible. Kotlin gives us better tools.

I've shipped 22 apps from Bangkok over 13 years. The ones that survive production longest are the ones where errors are values, not control flow. This post walks through the pattern I use in every codebase now.

The Problem With Exceptions

Exceptions break referential transparency. A function signature

code
fun fetchUser(id: String): User
lies—it might return a User, or it might throw
code
IOException
,
code
HttpException
, or
code
JsonParseException
. The caller discovers this at runtime, usually in a crash report.

AspectExceptionsResult Types
VisibilityHidden in docs/runtimeExplicit in signature
Compositiontry-catch nestingmap/flatMap chaining
ExhaustivenessCompiler can't verifySealed classes enforce handling
TestingRequires exception throwingPlain value assertions
Stack tracesExpensive allocationZero-cost values

[!WARNING] Exceptions allocate stack traces on every throw. In hot paths—network retries, database migrations, image decoding—this creates measurable GC pressure. Result types allocate a single object.

The Result Type Pattern

Kotlin's standard library includes

code
kotlin.Result
, a value-based wrapper for success or failure. It's generic, inline, and designed for exactly this use case.

kotlin
// kotlin.Result is inline and value-based
inline fun <T> Result<T>.getOrThrow(): T = 
    fold(onFailure = { throw it }, onSuccess = { it })

inline fun <T> Result<T>.getOrNull(): T? = 
    fold(onFailure = { null }, onSuccess = { it })

inline fun <T> Result<T>.getOrDefault(defaultValue: T): T = 
    fold(onFailure = { defaultValue }, onSuccess = { it })

But

code
kotlin.Result
wraps any
code
Throwable
. For domain modeling, I prefer a sealed hierarchy that makes error types explicit.

kotlin
sealed interface Result<out T> {
    data class Success<out T>(val value: T) : Result<T>
    data class Failure(val error: AppError) : Result<Nothing>
    
    fun <R> map(transform: (T) -> R): Result<R> = when (this) {
        is Success -> Success(transform(value))
        is Failure -> this
    }
    
    fun <R> flatMap(transform: (T) -> Result<R>): Result<R> = when (this) {
        is Success -> transform(value)
        is Failure -> this
    }
    
    fun fold<R>(onSuccess: (T) -> R, onFailure: (AppError) -> R): R = when (this) {
        is Success -> onSuccess(value)
        is Failure -> onFailure(error)
    }
}

sealed interface AppError {
    data class Network(val cause: Throwable?) : AppError
    data class Http(val statusCode: Int, val body: String?) : AppError
    data class Parsing(val cause: Throwable) : AppError
    data class NotFound(val resource: String) : AppError
    data class Unauthorized : AppError
    data class Validation(val fieldErrors: Map<String, List<String>>) : AppError
    data class Unknown(val cause: Throwable) : AppError
}

[!NOTE] The

code
inline
functions on
code
kotlin.Result
avoid boxing overhead. My sealed version isn't inline, but the allocation cost is one object per operation—negligible compared to network I/O.

Composing Results With Map and FlatMap

The power of Result appears when chaining operations. Each step either transforms the value or short-circuits with the first error.

kotlin
class UserRepository @Inject constructor(
    private val api: UserApi,
    private val cache: UserCache,
    private val mapper: UserMapper
) {
    suspend fun getUser(id: String): Result<User> = 
        cache.get(id)
            .flatMap { cached -> 
                if (cached.isFresh) Result.Success(cached.user)
                else fetchAndCache(id)
            }
            .flatMap { apiUser -> 
                Result.runCatching { mapper.toDomain(apiUser) }
                    .mapFailure { AppError.Parsing(it) }
            }
    
    private suspend fun fetchAndCache(id: String): Result<User> = 
        api.getUser(id)
            .map { Response.Success(it) }
            .recover { Response.Failure(it) }
            .flatMap { response ->
                when (response) {
                    is Response.Success -> cache.put(response.body).map { response.body }
                    is Response.Failure -> Result.Failure(mapApiError(response.error))
                }
            }
    
    private fun mapApiError(error: ApiError): AppError = when {
        error is ApiError.Network -> AppError.Network(error.cause)
        error is ApiError.Http -> AppError.Http(error.code, error.body)
        else -> AppError.Unknown(error)
    }
}

[!TIP] Use

code
flatMap
when the next step returns
code
Result
. Use
code
map
when it returns a plain value. Mixing them up is the most common bug—
code
map { fetchUser(it) }
gives you
code
Result<Result<User>>
.

Real-World Integration With ViewModels

The ViewModel layer consumes Result without try-catch. UI state becomes a simple fold.

kotlin
@HiltViewModel
class UserViewModel @Inject constructor(
    private val repository: UserRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {
    
    private val _uiState = MutableStateFlow<UserUiState.Loading>
    val uiState: StateFlow<UserUiState> = _uiState
    
    fun loadUser(userId: String) {
        viewModelScope.launch {
            _uiState.value = UserUiState.Loading
            val result = repository.getUser(userId)
            _uiState.value = result.fold(
                onSuccess = { UserUiState.Success(it) },
                onFailure = { UserUiState.Error(it) }
            )
        }
    }
    
    fun retry(userId: String) = loadUser(userId)
}

sealed interface UserUiState {
    data class Loading : UserUiState
    data class Success(val user: User) : UserUiState
    data class Error(val error: AppError) : UserUiState
}

The Compose screen handles each state explicitly—no surprise crashes.

kotlin
@Composable
fun UserScreen(viewModel: UserViewModel, userId: String) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
    when (uiState) {
        is UserUiState.Loading -> LoadingSpinner()
        is UserUiState.Success -> UserProfile(user = uiState.user)
        is UserUiState.Error -> ErrorView(
            error = uiState.error,
            onRetry = { viewModel.retry(userId) }
        )
    }
}

@Composable
fun ErrorView(error: AppError, onRetry: () -> Unit) {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(text = error.userMessage, style = MaterialTheme.typography.bodyLarge)
        Spacer(modifier = Modifier.padding(16.dp))
        Button(onClick = onRetry) {
            Text("Retry")
        }
    }
}

val AppError.userMessage: String
    get() = when (this) {
        is AppError.Network -> "No internet connection. Check your network."
        is AppError.Http -> when (statusCode) {
            401 -> "Session expired. Please sign in again."
            404 -> "User not found."
            500..599 -> "Server error. Please try again later."
            else -> "Request failed ($statusCode)."
        }
        is AppError.Parsing -> "Data format error. Please update the app."
        is AppError.NotFound -> "$resource not found."
        is AppError.Unauthorized -> "Please sign in to continue."
        is AppError.Validation -> "Please check the highlighted fields."
        is AppError.Unknown -> "Something went wrong. Please try again."
    }

[!IMPORTANT] Error messages live in the domain layer (

code
AppError.userMessage
), not the UI. This keeps copy consistent across screens and testable without rendering.

Testing Result-Based Code

Tests become pure function assertions. No mocking exception throwing, no

code
expectedException
rules.

kotlin
class UserRepositoryTest {
    private val api = mockk<UserApi>()
    private val cache = mockk<UserCache>()
    private val mapper = mockk<UserMapper>()
    private val repository = UserRepository(api, cache, mapper)
    
    @Test
    fun `getUser returns cached user when fresh`() = runTest {
        val cached = User(id = "1", name = "Test")
        coEvery { cache.get("1") } returns Result.Success(CachedUser(cached, true))
        
        val result = repository.getUser("1")
        
        assertTrue(result is Result.Success)
        assertEquals(cached, result.getOrNull())
        verify(exactly = 0) { api.getUser(any()) }
    }
    
    @Test
    fun `getUser fetches from API when cache stale`() = runTest {
        val apiUser = ApiUser(id = "1", name = "Test")
        val domainUser = User(id = "1", name = "Test")
        coEvery { cache.get("1") } returns Result.Success(CachedUser(domainUser, false))
        coEvery { api.getUser("1") } returns Result.Success(Response.Success(apiUser))
        coEvery { mapper.toDomain(apiUser) } returns domainUser
        coEvery { cache.put(domainUser) } returns Result.Success(Unit)
        
        val result = repository.getUser("1")
        
        assertTrue(result is Result.Success)
        assertEquals(domainUser, result.getOrNull())
    }
    
    @Test
    fun `getUser returns parsing error when mapper throws`() = runTest {
        val apiUser = ApiUser(id = "1", name = "Test")
        coEvery { cache.get("1") } returns Result.Success(CachedUser(User("1", ""), false))
        coEvery { api.getUser("1") } returns Result.Success(Response.Success(apiUser))
        coEvery { mapper.toDomain(apiUser) } throws JsonParseException("bad json")
        
        val result = repository.getUser("1")
        
        assertTrue(result is Result.Failure)
        assertTrue(result.fold(onSuccess = { false }, onFailure = { it is AppError.Parsing }))
    }
}

When Not to Use Result Types

Result types add ceremony. Not every function needs them.

Use ExceptionsUse Result Types
Programming bugs (null checks, index bounds)Expected failures (network, parsing, validation)
Library internals where caller can't recoverPublic API boundaries
Scripts and one-off toolsBusiness logic, repositories, use cases
Interop with Java APIs that throwCoroutine boundaries

[!NOTE] Kotlin's

code
Result
is perfect for interop.
code
runCatching { javaLib.call() }
converts any exception to
code
Result
instantly. Wrap at the boundary, then use your domain
code
Result
inside.

Key Takeaways

  • Model errors as data — Sealed
    code
    Result<Success, Failure<AppError>>
    makes every failure path visible in the type signature
  • Compose with map and flatMap — Chain operations without try-catch; the first error short-circuits the entire pipeline
  • Push error messages to the domain —
    code
    AppError.userMessage
    keeps UI copy consistent and testable
  • Wrap at boundaries — Convert exceptions to Result at API/cache edges using
    code
    runCatching
    , then stay in Result land
  • Reserve exceptions for bugs — Null pointers, index out of bounds, and contract violations should still crash loudly
Share:
S

Sudarshan Chaudhari

AI Systems Builder / Product Engineer

Bangkok, Thailand

Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.

Stay updated

Get new posts on Android, Kotlin, and solo dev straight to your inbox.

Newsletter preferences

Related Apps

MyFamilyTracker

Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.

Building something? Available for Android dev and QA consulting.

Work with me

Comments — powered by Giscus

Apps tagged with this