Error Propagation is a mechanism for propagating an error up the call stack from the point of occurrence to the handler. When a function cannot handle an error on its own, it passes it to the calling party through an exception, throws declaration, or Return Type. Proper implementation of propagation is critical for the stability of mobile applications: unhandled or incorrectly passed errors lead to crashes. According to Apple Swift Documentation (2026), automatic propagation via throws in Swift allows passing an error to any level without boilerplate code.
Key Takeaways
Error Propagation is the process of passing an error object from the function where it occurred up the call chain to the nearest suitable handler. Imagine the call stack: ViewController calls ViewModel, ViewModel calls Repository, Repository calls API. If the API returns a network error, it must go through Repository and ViewModel to ViewController, which will display a message to the user. Each intermediate function decides: handle the error or pass it further (propagate).
There are two approaches to propagation: automatic and manual. With automatic approach (Swift throws, Java checked exceptions) the compiler forces the developer to either handle the error or declare propagation in the signature. With manual approach (Result Type, Kotlin Try) the error is passed as a value — the developer explicitly writes code to pass or transform the error. According to Kotlin Result Docs (2026), Result<T> in Kotlin is not intended for propagation across function boundaries directly — it needs to be transformed or handled at each level, which makes propagation more conscious but also more verbose.
The choice of approach depends on the application architecture and language. Swift is dominated by automatic propagation via throws, Kotlin uses a mix of exceptions (for unexpected errors) and Result-like containers (for expected ones). It is important to understand: propagation is not a goal, but a necessity. An ideal architecture minimizes the depth of propagation, handling errors at the lowest possible level where there is enough context to make a decision.
In Swift, propagation via throws is automatic: if function A with throws calls function B with throws, and A does not handle B's error in do-catch, the error is automatically passed to A's caller. This eliminates the boilerplate code typical of Java checked exceptions, where throws must be declared in every method of the chain. Swift uses the principle “one throws-function in the chain = the whole chain becomes throws, if not handled at intermediate levels”.
struct UserRepository {
func fetchUser(id: Int) throws -> User {
let data = try networkService.request(path: "/users/\(id)")
return try parseUser(from: data)
}
}
class UserViewModel {
let repo = UserRepository()
func loadUser(id: Int) throws -> User {
return try repo.fetchUser(id: id)
}
}
// ViewController — final handler
func onButtonTap() {
let vm = UserViewModel()
do {
let user = try vm.loadUser(id: 42)
updateUI(user)
} catch {
showError("Failed to load user")
}
}
Propagation chain: networkService.request -> fetchUser -> loadUser -> onButtonTap. Each intermediate function is marked throws and does not contain do-catch — the error is automatically passed upward. ViewController onButtonTap is the final handler with do-catch. If ViewModel decided to transform the error (wrap it into another type), it could use do-catch and a new throw. Automatic propagation reduces code: Repository does not need to know how to handle the error — that is the responsibility of ViewController, which has access to the UI to display a message to the user.
In Kotlin, propagation via exceptions does not require a throws declaration in the signature (all exceptions are unchecked). The exception automatically rises up the stack until it encounters a try-catch. However, the absence of throws in the signature makes propagation implicit: the developer cannot see from the function signature that it might throw an exception. This is both a plus (less boilerplate) and a minus (easier to forget to handle) at the same time. Kotlin solves this problem through conventions and architectural patterns, not through the language itself.
class UserRepository(
private val api: ApiService,
private val db: Database
) {
suspend fun getUser(id: String): User {
return try {
api.fetchUser(id)
} catch (e: IOException) {
db.getCachedUser(id) ?: throw AppException("User unavailable")
}
}
}
class UserViewModel(private val repo: UserRepository) {
private val _state = MutableStateFlow<UiState<User>>(UiState.Loading)
val state: StateFlow<UiState<User>> = _state
fun loadUser(id: String) {
viewModelScope.launch {
try {
val user = repo.getUser(id)
_state.value = UiState.Success(user)
} catch (e: AppException) {
_state.value = UiState.Error(e.message ?: "Unknown")
}
}
}
}
In Repository, propagation with transformation: on IOException (network unavailable) the function tries to get cached data from the database. If the cache is empty, it throws AppException — propagation continues with a new error type. ViewModel catches AppException and translates it to UiState.Error — the error does not go further, propagation ends at the UI layer level. Kotlin Coroutines add specifics: exceptions in launch automatically propagate through CoroutineExceptionHandler, and in async — only when await() is called. This is important to consider when designing propagation in coroutines — SupervisorJob prevents cancellation of the parent coroutine upon an error in a child coroutine.
An alternative to exceptions is propagation through a container type that passes success or error as a value. In this approach, the function returns not a value but a wrapper: Result<T, E> in Swift, Result<T> in Kotlin, Either<L, R> in Dart (from the fpdart or dartz package). The error does not unwind the stack — it simply lies in the container, and the next level decides what to do with it. This makes propagation more explicit and controllable.
data class HttpResult<out T>(
val data: T?,
val error: AppError?
) {
val isSuccess: Boolean get() = data != null
val isError: Boolean get() = error != null
}
sealed class AppError {
data class Network(val message: String) : AppError()
data class Auth(val message: String) : AppError()
}
fun fetchUser(id: String): HttpResult<User> {
return try {
val response = api.get("/users/$id")
HttpResult(data = parseUser(response), error = null)
} catch (e: IOException) {
HttpResult(data = null, error = AppError.Network("No internet"))
}
}
HttpResult<T> is a simple container with data and error fields. Sealed class AppError defines error types (Network, Auth). The fetchUser function returns HttpResult, propagation does not require stack unwinding — the caller simply checks isSuccess/isError. This approach is especially useful in Clean Architecture, where each layer (data, domain, presentation) can transform the error: IOError -> DomainError -> UiError. Propagation through a container makes these transformations explicit and testable, unlike exceptions, where the transformation chain is not visible in function signatures.
One of the key decisions in error handling design is choosing between propagation (pass upward) and handling (handle here). The decision rule: handle the error at the level where there is enough context for a meaningful action. If you have access to the UI — show a message to the user. If you have access to the cache — try to recover. If you have neither — propagate.
| Scenario | Action | Rationale |
|---|---|---|
| Network error in Repository | Propagate | Repository does not know if the user wants to retry the request |
| Parsing error in Repository | Handle (return default) | Repository knows the format, can return a fallback value |
| Timeout in ViewModel | Handle (UiState.Error) | ViewModel manages UiState, knows how to translate the error |
| Authorization error in Interceptor | Handle (refresh token) | Interceptor has access to tokens and can restore the session |
| Unknown error in UseCase | Propagate | UseCase has no UI context — only business logic |
The golden rule: minimum propagation, maximum handling at lower levels. If Repository can recover from the cache — it should do so without passing the error upward. If ViewModel can show a Snackbar — let it show, without requiring additional code from ViewController. Each level of propagation increases coupling and complicates testing. According to Google Android Architecture Guide (2026), it is recommended to minimize propagation across layer boundaries by using sealed class UiState to represent all possible states (Loading, Success, Error) at the ViewModel level, and not to pass exceptions directly to the UI layer.
Incorrect propagation is a source of hard-to-find bugs in mobile applications. Let us consider five main problems that developers face and ways to solve them.
The most common problem: during propagation, an exception is caught, logged, and a new one is thrown without the original exception. The developer loses the StackTrace and cannot understand where exactly the error occurred. In Swift, use error chaining: throw MyError(context: originalError). In Kotlin: throw AppException(cause = originalException). In Dart: throw AppException(message, originalException). Never create a new exception without passing cause/underlyingError.
catch (e: Exception) { /* nothing */ } is an antipattern that causes the application to continue working in an incorrect state. If you are sure the error can be ignored — add a comment with justification. In Swift, use try? for optional ignoring (error -> nil). In Kotlin — Result<T>.onFailure { /* log */ }. Do not swallow exceptions without logging.
If an error passes through 5+ levels without handling, the architecture needs review. Each propagation level is a dependency on the throws signature of underlying functions. Solution: use Failure-containers (sealed class Result { Success, Error }) at layer boundaries to make propagation explicit and limited. The shorter the propagation chain, the easier it is to test and debug the code.
In Kotlin Coroutines, an exception in launch by default cancels the parent coroutine and all siblings (children of the same scope). If one of 10 parallel tasks fails, the other 9 will be cancelled, which is often undesirable. Use SupervisorJob or supervisorScope to isolate errors: an error in one child does not cancel siblings. ViewModelScope uses SupervisorJob by default, which saves from this problem in Android.
In callback-based APIs, the error is often passed as a callback parameter. If the callback does not handle the error (or handles it incorrectly), propagation becomes implicit and easily lost. Solution: migrate to async/await (Swift) or coroutines (Kotlin), where propagation works through standard try-catch mechanisms. If callback is unavoidable — use Either<Error, T> or Result<T> for forced handling of both cases.
Frequently Asked Questions
Throw is a one-time action of throwing an exception. Error Propagation is the entire process of passing an error through several stack levels, from throw to catch. Propagation includes throw, automatic or manual passing through intermediate functions, and final handling. It is a broader concept describing the error's lifecycle.
Use mock objects that throw exceptions in given scenarios. Check that the function correctly propagates or handles the error via assertThrows (Kotlin/JUnit) or XCTAssertThrowsError (Swift/XCTest). For Result-based propagation, check isSuccess/isError and values in both cases.
Result propagation is preferable for expected errors (invalid data, business rules) within one architectural boundary. Exceptions are better for unexpected errors (network loss, I/O errors) that should be handled at a high level. A result with an error does not interrupt the execution flow, an exception does.
In Kotlin Coroutines, an exception in launch automatically propagates through CoroutineScope with cancellation of siblings. Use supervisorScope or SupervisorJob for isolation: an error in one coroutine does not cancel others. For async, the error must be explicitly handled via try-catch when calling await(), otherwise it will be swallowed.
The ideal final handler is the UI layer (ViewController, Fragment/Composable). Only it has access to the user interface and can display a message, Snackbar, or dialog. Intermediate layers (Repository, UseCase, ViewModel) propagate the error, transforming it if necessary into a more abstract domain type.
Summary
We will develop a mobile application turnkey
IT Sectr creates iOS and Android applications for startups and businesses since 2017. We will advise you and propose the best solution.
Read also