Introduction

When I first started using Kotlin, I relied heavily on throwing exceptions to signal errors. While this works for many scenarios, it clutters the call stack and forces callers to write verbose try‑catch blocks. A few years ago I switched to Kotlin's built‑in Result type, and the difference in code clarity was striking. In this article I'll show you how Result can simplify error handling, illustrate a real‑world example from an Android networking layer, and explain why the functional approach feels more natural in Kotlin.

What Is Result?

Kotlin 1.13 introduced Result as a sealed class with two subclasses: Success and Failure. It mirrors the Option/Either patterns found in other languages but is baked directly into the standard library. Instead of unwrapping a nullable or catching an exception, you work with a value that is either a success or a failure, and you can chain transformations safely.

The core functions are:

  • map – transform a successful value.
  • flatMap – transform and flatten nested Results.
  • fold – apply different functions for success and failure.
  • getOrElse / getOrNull – fallback to a default.

These methods are infix‑friendly and compose beautifully, which is why they fit Kotlin's idiomatic style.

When Does It Shine? A Real‑World Scenario

Imagine you're building an Android app that fetches a user's profile from a REST API. The repository layer currently returns a User or throws NetworkException. Every caller must wrap the call in a try‑catch, and the UI layer needs to show a generic error dialog. With Result you can express the same contract without exceptions.

Tip: Use Result for any operation that can succeed or fail without throwing, especially I/O, database, or validation logic.

Below is the repository method that returns a Result:

 sealed class Result<out T> {
    data class Success<T>(val value: T) : Result<T>()
    data class Failure<T>(val error: Throwable) : Result<T>()
}

interface UserRepository {
    fun getUser(userId: String): Result<User>
}

class RemoteUserRepository(private val api: UserApi) : UserRepository {
    override fun getUser(userId: String): Result<User> {
        return try {
            val dto = api.fetchUser(userId)
            Result.Success(dto.toDomain())
        } catch (e: Exception) {
            Result.Failure(e)
        }
    }
}

The caller can now chain operations without worrying about nulls or exceptions:

val userResult = repository.getUser("123")
    .map { it.name }
    .map { name -> name.uppercase() }
    .fold(
        onSuccess = { name -> Log.d("Profile", "Welcome $name") },
        onFailure = { error -> Analytics.recordError(error) }
    )

Notice how the fold call lets you handle success and failure in a single place. You don't need a separate nullable check or a try‑catch block.

Why Not Just Use Exceptions?

Exceptions are great for truly exceptional cases—like programming errors—but they become noisy when a method is expected to fail under normal operation. Each exception creates a stack frame, which can impact performance and make debugging harder. With Result you keep the flow linear, and you can treat failure as a first‑class value.

Another advantage is composability. If you have multiple async steps, you can pipe Result through flatMap without nesting callbacks. This matches Kotlin's emphasis on expressive, readable code.

Handling Null Safety

Result already encapsulates the possibility of failure, so you don't need to deal with null checks after unwrapping. For example, a map that returns Result<String> will never produce a null inside the success branch. This eliminates a whole class of NullPointerException bugs that are otherwise common in Kotlin when mixing nullable types and imperative error handling.

Practical Tips for Using Result

  • Prefer Result over throwing. Only throw when you have a truly unexpected error (e.g., a bug in your code).
  • Combine with coroutines. You can suspend the API call and still return Result directly: suspend fun fetchUser(id: String): Result<User>.
  • Use custom subclasses. If you need richer failure information, extend Failure with properties like HTTP status code or error codes.
  • Remember that Result is immutable. This makes it safe to pass around and log without side effects.

Putting It All Together

Let's look at a complete usage example that demonstrates the whole flow—from the data layer to the UI. The code below is production‑ready and includes comments explaining each step.

// Data layer
class ProfileUseCase(private val repo: UserRepository) {
    suspend fun loadProfile(userId: String): Result<Profile> = withContext(Dispatchers.IO) {
        repo.getUser(userId)
            .map { user -> user.id to user.email }
            .map { (id, email) -> fetchSettings(id) }
            .flatMap { settings -> combine(settings, email) }
    }

    private fun fetchSettings(userId: String): Result<UserSettings> {
        // simulated DB call, returns Result
    }

    private fun combine(settings: UserSettings, email: String): Result<Profile> {
        return if (settings.notificationsEnabled) {
            Result.Success(Profile(email, settings.theme))
        } else {
            Result.Failure(IllegalStateException("Notifications disabled"))
        }
    }
}

// UI layer
class ProfileViewModel(private val useCase: ProfileUseCase) : ViewModel() {
    private val _state = MutableLiveData<UiState>()
    val state: LiveData<UiState> = _state

    fun loadProfile(userId: String) {
        viewModelScope.launch {
            useCase.loadProfile(userId)
                .fold(
                    onSuccess = { profile ->
                        _state.postValue(UiState.Success(profile))
                    },
                    onFailure = { error ->
                        _state.postValue(UiState.Error(error.message))
                    }
                )
        }
    }
}

In this snippet the UI never catches an exception; it simply observes the Result and updates the UI state accordingly. The error handling is centralized in the ViewModel, making the code easier to test and reason about.

Conclusion

Embracing Kotlin's Result type shifts error handling from an exception‑centric mindset to a value‑centric one. The pattern reduces boilerplate, improves readability, and integrates smoothly with coroutines and the rest of Kotlin's ecosystem. By making failure a first‑class citizen, you write more resilient code that is easier to test, log, and compose.

If you haven't tried Result yet, now is a good time to give it a spin in your next Android or server project. You might find that the functional flow feels more natural and that your callers become cleaner almost instantly.