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

PatternWhen to Use
Result<T>Simple success/failure with Kotlin stdlib
Resource<T>Custom sealed class with Loading state
AppError sealed classStructured 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:

View source code on GitHub →

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.