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 type | Genuinely 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.
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.ApiResult.Error the user saw — becomes a small addition on top of this same foundation, rather than a redesign.