Your app will crash. The network will fail. The server will return unexpected data. The database will be empty when it should not be.
The question is not if errors happen — it is how you handle them. Good error handling makes the difference between an app that shows a helpful message and an app that shows a blank screen.
In KMP, you need error handling that works on every platform. No Android-specific exceptions, no iOS-specific patterns. Shared error handling for shared code.
The Problem with Try-Catch Everywhere
Many projects use try-catch in every function:
// BAD — try-catch everywhere, hard to maintain
class NoteRepository(private val api: NoteApi) {
suspend fun getNotes(): List<Note> {
return try {
api.getNotes()
} catch (e: Exception) {
emptyList() // Silently swallows the error
}
}
}
This approach has problems:
- Errors are swallowed silently — you do not know something went wrong
- Every function has boilerplate try-catch
- The caller cannot tell if the empty list means “no notes” or “network failed”
The Result Pattern
Kotlin has a built-in Result<T> type that represents either success or failure:
// Result.success(value) — operation succeeded
// Result.failure(exception) — operation failed
suspend fun getNotes(): Result<List<Note>> {
return try {
val notes = api.getNotes()
Result.success(notes)
} catch (e: Exception) {
Result.failure(e)
}
}
The caller can then decide what to do:
val result = repository.getNotes()
result.onSuccess { notes ->
// Show notes
}.onFailure { error ->
// Show error message
}
// Or use fold
result.fold(
onSuccess = { notes -> showNotes(notes) },
onFailure = { error -> showError(error.message) }
)
// Or use getOrNull / getOrDefault
val notes = result.getOrNull() ?: emptyList()
val notes = result.getOrDefault(emptyList())
Custom Error Types with Sealed Classes
Result<T> uses Exception for errors. For more structured errors, create a sealed hierarchy:
// shared/src/commonMain/kotlin/domain/error/AppError.kt
sealed class AppError(
val message: String,
val cause: Throwable? = null
) {
// Network errors
class NetworkError(
message: String = "Network connection failed",
cause: Throwable? = null
) : AppError(message, cause)
class ServerError(
val code: Int,
message: String = "Server error ($code)",
cause: Throwable? = null
) : AppError(message, cause)
class Timeout(
message: String = "Request timed out",
cause: Throwable? = null
) : AppError(message, cause)
// Data errors
class NotFound(
message: String = "Item not found"
) : AppError(message)
class ValidationError(
message: String
) : AppError(message)
// Auth errors
class Unauthorized(
message: String = "Please log in again"
) : AppError(message)
// Generic
class Unknown(
message: String = "Something went wrong",
cause: Throwable? = null
) : AppError(message, cause)
}
A Custom Result Type
Wrap AppError in a result type that is cleaner than Result<T>:
// shared/src/commonMain/kotlin/domain/error/Resource.kt
sealed class Resource<out T> {
data class Success<T>(val data: T) : Resource<T>()
data class Error(val error: AppError) : Resource<Nothing>()
data object Loading : Resource<Nothing>()
}
Now your repository returns structured results:
class NoteRepositoryImpl(
private val api: NoteApiService
) : NoteRepository {
override suspend fun getNotes(): Resource<List<Note>> {
return try {
val notes = api.getNotes().map { it.toDomain() }
Resource.Success(notes)
} catch (e: Exception) {
Resource.Error(e.toAppError())
}
}
}
Mapping Exceptions to App Errors
Create a helper that converts exceptions to your error types:
// shared/src/commonMain/kotlin/domain/error/ErrorMapper.kt
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.ServerResponseException
import io.ktor.http.HttpStatusCode
import io.ktor.utils.io.errors.IOException
fun Throwable.toAppError(): AppError {
return when (this) {
is ClientRequestException -> {
when (response.status) {
HttpStatusCode.Unauthorized -> AppError.Unauthorized()
HttpStatusCode.NotFound -> AppError.NotFound()
else -> AppError.ServerError(
code = response.status.value,
message = "Request failed: ${response.status.description}"
)
}
}
is ServerResponseException -> {
AppError.ServerError(
code = response.status.value,
message = "Server error: ${response.status.description}"
)
}
is IOException -> {
if (message?.contains("timeout", ignoreCase = true) == true) {
AppError.Timeout(cause = this)
} else {
AppError.NetworkError(cause = this)
}
}
else -> AppError.Unknown(
message = message ?: "Unknown error",
cause = this
)
}
}
Now every exception is converted to a meaningful AppError that the UI can display.
Safe API Call Wrapper
Instead of try-catch in every repository method, create a reusable wrapper:
// shared/src/commonMain/kotlin/data/remote/safeApiCall.kt
suspend fun <T> safeApiCall(block: suspend () -> T): Resource<T> {
return try {
Resource.Success(block())
} catch (e: Exception) {
Resource.Error(e.toAppError())
}
}
Use it in your repository:
class NoteRepositoryImpl(private val api: NoteApiService) : NoteRepository {
override suspend fun getNotes(): Resource<List<Note>> = safeApiCall {
api.getNotes().map { it.toDomain() }
}
override suspend fun getNoteById(id: String): Resource<Note> = safeApiCall {
api.getNoteById(id).toDomain()
}
override suspend fun createNote(title: String, content: String): Resource<Note> = safeApiCall {
api.createNote(title, content).toDomain()
}
override suspend fun deleteNote(id: String): Resource<Unit> = safeApiCall {
api.deleteNote(id)
}
}
Clean, consistent, and every method handles errors the same way.
Using Errors in the ViewModel
// shared/src/commonMain/kotlin/viewmodel/NoteListViewModel.kt
data class NoteListState(
val notes: List<Note> = emptyList(),
val isLoading: Boolean = false,
val error: AppError? = null
)
class NoteListViewModel(
private val noteRepository: NoteRepository
) : ViewModel() {
private val _state = MutableStateFlow(NoteListState())
val state: StateFlow<NoteListState> = _state.asStateFlow()
init {
loadNotes()
}
fun loadNotes() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
when (val result = noteRepository.getNotes()) {
is Resource.Success -> {
_state.update {
it.copy(notes = result.data, isLoading = false)
}
}
is Resource.Error -> {
_state.update {
it.copy(error = result.error, isLoading = false)
}
}
is Resource.Loading -> { /* handled by isLoading flag */ }
}
}
}
fun dismissError() {
_state.update { it.copy(error = null) }
}
}
Displaying Errors in UI
@Composable
fun NoteListScreen(viewModel: NoteListViewModel = koinViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
when {
state.isLoading -> LoadingIndicator()
state.error != null -> ErrorScreen(state.error!!, viewModel)
else -> NoteList(state.notes)
}
}
@Composable
fun ErrorScreen(error: AppError, viewModel: NoteListViewModel) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Show different messages for different error types
val message = when (error) {
is AppError.NetworkError -> "No internet connection. Check your network and try again."
is AppError.Timeout -> "The request took too long. Please try again."
is AppError.ServerError -> "Something went wrong on the server (${error.code})."
is AppError.Unauthorized -> "Your session expired. Please log in again."
else -> error.message
}
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
// Show retry for recoverable errors
if (error is AppError.NetworkError || error is AppError.Timeout || error is AppError.ServerError) {
Button(onClick = { viewModel.loadNotes() }) {
Text("Try Again")
}
}
}
}
Different errors get different messages and different actions. Network errors show a retry button. Auth errors redirect to login.
Logging with Kermit
Kermit by Touchlab is the standard logging library for KMP. It writes to Logcat on Android, OSLog on iOS, and console on Desktop/Web.
Setup
# gradle/libs.versions.toml
[versions]
kermit = "2.0.5"
[libraries]
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
// shared/build.gradle.kts
commonMain.dependencies {
implementation(libs.kermit)
}
Basic Usage
import co.touchlab.kermit.Logger
// Simple logging
Logger.d("UserRepository") { "Loading users..." }
Logger.i("UserRepository") { "Loaded 42 users" }
Logger.w("UserRepository") { "Cache expired, fetching from API" }
Logger.e("UserRepository") { "Failed to load users: ${error.message}" }
Log levels:
Logger.v { }— Verbose (noisy debug info)Logger.d { }— Debug (development info)Logger.i { }— Info (normal events)Logger.w { }— Warning (something unusual)Logger.e { }— Error (something broke)
Tag-Based Logger
Create a logger with a tag for each class:
class NoteRepositoryImpl(
private val api: NoteApiService
) : NoteRepository {
private val log = Logger.withTag("NoteRepository")
override suspend fun getNotes(): Resource<List<Note>> {
log.d { "Fetching notes from API..." }
return try {
val notes = api.getNotes().map { it.toDomain() }
log.i { "Loaded ${notes.size} notes" }
Resource.Success(notes)
} catch (e: Exception) {
log.e(e) { "Failed to fetch notes" }
Resource.Error(e.toAppError())
}
}
}
Configure Log Level
In production, you do not want verbose logs:
// shared/src/commonMain/kotlin/di/LoggingConfig.kt
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
fun configureLogging(isDebug: Boolean) {
Logger.setMinSeverity(
if (isDebug) Severity.Debug else Severity.Warn
)
}
Call this during app initialization. In debug builds, you see everything. In production, only warnings and errors.
Combining Error Handling with Logging
Here is the complete pattern — safe API call + error mapping + logging:
// shared/src/commonMain/kotlin/data/remote/safeApiCall.kt
import co.touchlab.kermit.Logger
private val log = Logger.withTag("SafeApiCall")
suspend fun <T> safeApiCall(
tag: String = "",
block: suspend () -> T
): Resource<T> {
return try {
val result = block()
if (tag.isNotEmpty()) {
log.d { "$tag: success" }
}
Resource.Success(result)
} catch (e: Exception) {
val appError = e.toAppError()
log.e(e) { "$tag: ${appError.message}" }
Resource.Error(appError)
}
}
// Usage
override suspend fun getNotes(): Resource<List<Note>> = safeApiCall("getNotes") {
api.getNotes().map { it.toDomain() }
}
Every API call is logged. Errors are mapped to structured types. The ViewModel never sees raw exceptions.
Crash Reporting
Kermit can forward logs to crash reporting services like Crashlytics:
// shared/src/androidMain/kotlin/logging/CrashlyticsLogWriter.kt
import co.touchlab.kermit.LogWriter
import co.touchlab.kermit.Severity
import com.google.firebase.crashlytics.FirebaseCrashlytics
class CrashlyticsLogWriter : LogWriter() {
override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) {
if (severity >= Severity.Warn) {
FirebaseCrashlytics.getInstance().log("$tag: $message")
throwable?.let {
FirebaseCrashlytics.getInstance().recordException(it)
}
}
}
}
// Add to your logging configuration
Logger.addLogWriter(CrashlyticsLogWriter())
On iOS, you can create a similar writer for Firebase Crashlytics or another crash reporting service.
Quick Reference
| Pattern | When to Use |
|---|---|
Result<T> | Simple success/failure with Kotlin stdlib |
Resource<T> | Custom sealed class with Loading state |
AppError sealed class | Structured error types for UI display |
safeApiCall { } | Wrap API calls with error mapping |
Throwable.toAppError() | Convert exceptions to your error types |
Logger.withTag("Tag") | Create a tagged logger per class |
Logger.setMinSeverity() | Filter logs by level (Debug/Warn/Error) |
Source Code
The KMP tutorial project is on GitHub:
Related Tutorials
- KMP Tutorial #6: Ktor Client — networking where errors originate
- KMP Tutorial #11: Clean Architecture — where error handling fits in the layers
- KMP Tutorial #13: Testing — testing error scenarios
- KMP Tutorial #10: Shared ViewModel — handling errors in ViewModel state
What’s Next?
In the next tutorial, we will start building a real app with KMP — a cross-platform notes app that uses everything we have learned so far: Ktor, SQLDelight, Koin, shared ViewModels, clean architecture, navigation, and proper error handling.
See you there.