Error Handling with Sealed Results

TrailRepository's getTrails() function, back in the Repository Pattern lesson, caught exceptions and silently fell back to the Room cache — a reasonable default, but it throws away exactly the information the UI would need to tell the user why nothing fresh loaded. Worse, if you let a raw network exception propagate all the way up unhandled, the ViewModel or UI layer would need to know about specific exception types like IOException or HttpException just to handle a failure — coupling the UI to networking internals it shouldn't need to care about. This lesson fixes both problems with one pattern.

The Fix: Wrapping Outcomes in a Sealed Result Type

This is the exact sealed class pattern from Data Classes & Sealed Classes — a fixed, compiler-known set of possible outcomes — applied to a real, practical problem: representing the result of a network call as data instead of as a thrown exception.

sealed class ApiResult<out T> {
    data class Success<T>(val data: T) : ApiResult<T>()
    data class Error(val message: String) : ApiResult<Nothing>()
}

ApiResult.Success and ApiResult.Error are the only two subclasses that can ever exist — exactly the same guarantee the earlier TrailLoadResult example relied on. ApiResult.Error extends ApiResult<Nothing> because an error carries no actual data of type T, and Nothing is a valid substitute for any type parameter in Kotlin's type system.

Catching the Exception Inside the Repository

The try/catch now lives in the repository, same as before — but instead of silently swallowing the failure, it converts it into an explicit ApiResult:

class TrailRepository(
    private val trailDao: TrailDao,
    private val api: TrailApiService
) {
    suspend fun getTrails(): ApiResult<List<Trail>> {
        return try {
            val freshTrails = api.getTrails()
            trailDao.insertAll(freshTrails)
            ApiResult.Success(freshTrails)
        } catch (e: IOException) {
            // no connection at all — fall back to cache, but still report it
            ApiResult.Success(trailDao.getAllTrails())
        } catch (e: HttpException) {
            ApiResult.Error("The server couldn't process that request (code ${e.code()}).")
        } catch (e: SerializationException) {
            ApiResult.Error("Received an unexpected response format.")
        }
    }
}

The exception-handling logic — knowing what an IOException versus an HttpException even means — stays fully contained inside the repository. Nothing above it in the app ever needs to import those exception types.

Handling It With an Exhaustive when in the ViewModel

The ViewModel consumes the result with a when expression over the sealed class — exactly the compiler-enforced exhaustiveness benefit covered in the Data Classes & Sealed Classes lesson: if a third ApiResult subclass gets added later and this when isn't updated, the project simply won't compile.

fun loadTrails() {
    viewModelScope.launch {
        when (val result = repository.getTrails()) {
            is ApiResult.Success -> _trails.value = result.data
            is ApiResult.Error -> _errorMessage.value = result.message
        }
    }
}

The ViewModel never touches IOException, HttpException, or any networking type at all — it only ever reasons about two plain data cases, Success and Error, which is exactly the level of detail a UI layer should have to think about.

Distinguishing Failure Types With Useful Messages

Not every failure deserves the same message. A generic "Something went wrong" is technically true but rarely helpful — mapping each real failure category to something specific is what actually helps a TrailLog user understand what happened:

Failure typeGenuinely useful message
No internet connection (IOException)"No internet connection. Showing your last saved trails."
Server error (HttpException, e.g. 500)"The server had a problem. Please try again in a moment."
Malformed response (SerializationException)"Something's off with the data we received. We've noted the issue."

Each row maps to one distinct catch block in TrailRepository above, and each message tells the user something they can actually act on — wait for a connection, retry shortly, or simply understand it's not something they did wrong.

Note: ApiResult, the repository code, and the ViewModel above are correct, real Kotlin — but this website's own project doesn't run a live Android Studio build, so nothing here executes on the page itself.
A natural next step: Once failures are surfaced this cleanly, retrying a failed request — automatically, or with a "Retry" button tied to the specific ApiResult.Error the user saw — becomes a small addition on top of this same foundation, rather than a redesign.