Articles / Technical
Error Handling in Kotlin Multiplatform: Beyond Try-Catch
Most KMP apps treat error handling as an afterthought, with scattered try-catch blocks, inconsistent error messages, and no clear contract between the data layer and the UI. This article covers how to build a structured error handling system in Kotlin Multiplatform that works consistently across Android, iOS, and any other target you add later.
The problem with generic exceptions
When something goes wrong in a typical KMP app, the exception that bubbles up to the ViewModel is usually a raw IOException, HttpException, or a platform-specific error. The ViewModel then has to decide: is this a network error? An auth failure? A server error? Should I show a snackbar or a dialog? Should I offer a retry button?
This logic ends up scattered across every ViewModel, often duplicated, and inconsistent. The iOS target and the Android target might handle the same error differently. A token expiry might show a snackbar on one screen and crash silently on another.
The root cause is that standard exceptions carry no UI metadata. They tell you what went wrong technically, but nothing about how the user should be told about it.
Actionable exceptions
The solution is to enrich exceptions with the information the UI needs. Instead of throwing a raw IOException, you throw an ActionableException that carries:
- →ErrorSeverity: Info, Warning, Error, or Critical
- →ErrorPresentation: Snackbar, Dialog, FullScreen, or Silent
- →A user-facing message (plain text, resource ID, or formatted with args)
- →Optional primary and secondary actions (Retry, Dismiss, Cancel, or custom)
- →Optional navigation directive (Login, Back, Home, or a custom route)
What this looks like in practice
In the data layer, when a request fails with a 401, you throw an AuthException rather than propagating the raw HTTP error. The AuthException is pre-configured: it has ErrorSeverity.Error, ErrorPresentation.Dialog, a "Session expired" message, an ErrorAction.Retry primary button, and an ErrorNavigation.Login directive.
The ViewModel catches ActionableException (the base type) and posts it to a StateFlow. It doesn't need to know anything about severity or presentation; that decision was made when the exception was created.
The UI layer collects the error state and passes it to ErrorPresenter, a single Compose component that routes the error to the right component automatically. Dialog errors show a Material 3 dialog. Snackbar errors show a snackbar. FullScreen errors replace the content area. Silent errors are logged without disturbing the user.
The ViewModel becomes a pass-through for errors it doesn't understand. Only truly ViewModel-specific errors need custom handling.
Centralised error messages and i18n
Hardcoding error messages in the exception constructor works for small apps, but it doesn't scale. If the same network error can be thrown from twenty different places, the message needs to be defined once.
A separate error-catalog module solves this. You define a structured catalog of error codes and their messages, then use factory functions to create exceptions: networkException(), authException(), tokenExpiredException(). Each factory function creates the exception with the right defaults (severity, presentation, message) and lets you override any of them at the call site.
Adding i18n means setting a locale once at app startup (DefaultMessageResolver.setLocale("es")) and every factory function automatically uses the right translation. English, Spanish, and French are built in; adding a new language means adding one translations file.
Custom actions and navigation
Not every error in your app maps to a generic "Retry" or "Dismiss". A video playback error might need a "Skip Video" action. A payment failure might need to navigate to a specific payment settings screen.
ErrorAction and ErrorNavigation are open classes, so you can extend them with app-specific types or use ErrorAction.Custom(actionId = "skip_video", ...) without modifying the library.
The UI handler pattern-matches on the actionId string, keeping the action definition close to the exception and the handler close to the navigation logic.
Plugging in error reporting
A structured error system also makes analytics and crash reporting much cleaner. Instead of catching exceptions in twenty places and calling Crashlytics.log(), you register an ErrorReporter once at app startup.
The reporter receives every ActionableException that gets displayed, including the error ID, severity, and any underlying cause. You can log to Firebase Crashlytics, your own analytics pipeline, or both, without changing any ViewModel or UI code.
In summary
Error handling is infrastructure. A well-designed error system means every new feature automatically gets consistent, translatable, user-friendly errors, without the developer who writes it having to think about presentation or navigation. The structured approach takes more upfront thought than a scattered try-catch, but it pays back every time you add a new screen, a new error type, or a new target platform.
Building something in South Africa?
Black Arrows is a Johannesburg-based mobile app and software consultancy. If you're working through a decision like the ones in this article, a short conversation is usually the fastest way to get clarity.